From a4ab2649677458f0fa585b16f445f05f21a7f8d2 Mon Sep 17 00:00:00 2001 From: frabacche Date: Fri, 15 Mar 2024 14:56:43 +0100 Subject: [PATCH 01/10] let's start the bystep branch --- coar-notify-7-integration-bystep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 coar-notify-7-integration-bystep diff --git a/coar-notify-7-integration-bystep b/coar-notify-7-integration-bystep new file mode 100644 index 00000000000..e69de29bb2d From f70aa7695327421a3180c159d3b0613429c48168 Mon Sep 17 00:00:00 2001 From: frabacche Date: Thu, 21 Mar 2024 12:07:04 +0100 Subject: [PATCH 02/10] QA + PC + LDN: let's start the bystep branch --- cypress.config.ts | 1 + cypress/plugins/index.ts | 24 + .../admin-ldn-services-routing.module.ts | 47 + .../admin-ldn-services.module.ts | 25 + .../ldn-service-form.component.html | 315 +++ .../ldn-service-form.component.scss | 143 ++ .../ldn-service-form.component.spec.ts | 241 +++ .../ldn-service-form.component.ts | 578 +++++ .../ldnServicesRD$-mock.ts | 111 + .../ldn-itemfilter-data.service.spec.ts | 89 + .../ldn-itemfilters-data.service.ts | 61 + .../ldn-services-data.service.spec.ts | 131 ++ .../ldn-services-data.service.ts | 217 ++ .../ldn-services-directory.component.html | 99 + .../ldn-services-directory.component.scss | 29 + .../ldn-services-directory.component.spec.ts | 163 ++ .../ldn-services-directory.component.ts | 176 ++ .../ldn-service-itemfilters.ts | 31 + .../ldn-service-patterns.model.ts | 13 + .../ldn-service-status.model.ts | 8 + .../ldn-service.constrain.model.ts | 3 + .../ldn-service.resource-type.ts | 12 + .../ldn-services-model/ldn-services.model.ts | 72 + .../service-constrain-type.model.ts | 10 + .../ldn-service-coar-patterns.ts | 16 + ...publication-claim-page-resolver.service.ts | 32 + ...ions-publication-claim-page.component.html | 1 + ...s-publication-claim-page.component.spec.ts | 40 + ...ations-publication-claim-page.component.ts | 9 + .../admin-notifications-routing-paths.ts | 7 + .../admin-notifications-routing.module.ts | 140 ++ .../admin-notifications.module.ts | 33 + .../admin-notify-dashboard-routing.module.ts | 64 + .../admin-notify-dashboard.component.html | 23 + .../admin-notify-dashboard.component.spec.ts | 57 + .../admin-notify-dashboard.component.ts | 95 + .../admin-notify-dashboard.module.ts | 56 + .../admin-notify-detail-modal.component.html | 22 + ...dmin-notify-detail-modal.component.spec.ts | 36 + .../admin-notify-detail-modal.component.ts | 49 + .../admin-notify-incoming.component.html | 23 + .../admin-notify-incoming.component.spec.ts | 58 + .../admin-notify-incoming.component.ts | 19 + .../admin-notify-logs-result.component.html | 25 + ...admin-notify-logs-result.component.spec.ts | 49 + .../admin-notify-logs-result.component.ts | 79 + .../admin-notify-outgoing.component.html | 24 + .../admin-notify-outgoing.component.spec.ts | 57 + .../admin-notify-outgoing.component.ts | 19 + .../admin-notify-metrics.component.html | 9 + .../admin-notify-metrics.component.spec.ts | 71 + .../admin-notify-metrics.component.ts | 53 + .../admin-notify-metrics.model.ts | 19 + .../admin-notify-search-result.component.html | 51 + ...min-notify-search-result.component.spec.ts | 182 ++ .../admin-notify-search-result.component.ts | 157 ++ ...dmin-notify-message-search-result.model.ts | 8 + .../models/admin-notify-message.model.ts | 165 ++ .../admin-notify-message.resource-type.ts | 9 + .../admin-notify-messages.service.spec.ts | 114 + .../services/admin-notify-messages.service.ts | 100 + .../admin-reports-routing.module.ts | 37 + .../admin-reports/admin-reports.module.ts | 28 + .../filtered-collection.model.ts | 36 + .../filtered-collections.component.html | 64 + .../filtered-collections.component.scss | 3 + .../filtered-collections.component.spec.ts | 83 + .../filtered-collections.component.ts | 70 + .../filtered-collections.model.ts | 26 + .../filtered-items/filtered-items-model.ts | 23 + .../filtered-items.component.html | 175 ++ .../filtered-items.component.scss | 3 + .../filtered-items.component.ts | 336 +++ .../filtered-items/option-vo.model.ts | 50 + .../filtered-items/preset-query.model.ts | 17 + .../filtered-items/query-predicate.model.ts | 36 + .../filters-section/filter-group.model.ts | 19 + .../filters-section/filter.model.ts | 8 + .../filters-section.component.html | 19 + .../filters-section.component.spec.ts | 101 + .../filters-section.component.ts | 148 ++ src/app/admin/admin-routing-paths.ts | 22 + src/app/admin/admin-routing.module.ts | 34 +- src/app/admin/admin.module.ts | 2 + src/app/app-routing-paths.ts | 3 + src/app/app-routing.module.ts | 19 + .../browse-by-metadata.component.html | 21 + .../community-list.component.html | 2 +- .../navigation-breadcrumb.resolver.spec.ts | 52 + .../navigation-breadcrumb.resolver.ts | 52 + .../navigation-breadcrumb.service.ts | 30 + .../navigation-breadcrumbs.service.spec.ts | 43 + ...lication-claim-breadcrumb.resolver.spec.ts | 31 + .../publication-claim-breadcrumb.resolver.ts | 24 + ...blication-claim-breadcrumb.service.spec.ts | 51 + .../publication-claim-breadcrumb.service.ts | 46 + ...lity-assurance-breadcrumb.resolver.spec.ts | 31 + .../quality-assurance-breadcrumb.resolver.ts | 32 + ...ality-assurance-breadcrumb.service.spec.ts | 39 + .../quality-assurance-breadcrumb.service.ts | 45 + .../core/cache/builders/build-decorators.ts | 43 +- .../builders/remote-data-build.service.ts | 1 + .../notify-info/notify-info.component.html | 18 + .../notify-info/notify-info.component.spec.ts | 37 + .../notify-info/notify-info.component.ts | 38 + .../notify-info/notify-info.guard.spec.ts | 49 + .../notify-info/notify-info.guard.ts | 30 + .../notify-info/notify-info.service.spec.ts | 56 + .../notify-info/notify-info.service.ts | 52 + src/app/core/core.module.ts | 36 +- .../core/data/collection-data.service.spec.ts | 9 +- .../data/feature-authorization/feature-id.ts | 2 + ...otify-services-status-data.service.spec.ts | 81 + .../notify-services-status-data.service.ts | 45 + src/app/core/data/update-data.service.spec.ts | 144 ++ src/app/core/data/update-data.service.ts | 310 ++- .../builder/json-patch-operations-builder.ts | 15 + .../json-patch-operations.actions.ts | 30 +- .../json-patch-operations.reducer.ts | 66 +- .../suggestion-objects.resource-type.ts | 9 + .../suggestion-source-object.resource-type.ts | 9 + .../models/suggestion-source.model.ts | 47 + .../suggestion-target-object.resource-type.ts | 9 + .../models/suggestion-target.model.ts | 61 + .../notifications/models/suggestion.model.ts | 88 + ...ality-assurance-event-data.service.spec.ts | 248 +++ .../quality-assurance-event-data.service.ts | 252 +++ ...ty-assurance-event-object.resource-type.ts | 9 + .../models/quality-assurance-event.model.ts | 173 ++ ...y-assurance-source-object.resource-type.ts | 9 + .../models/quality-assurance-source.model.ts | 52 + ...ty-assurance-topic-object.resource-type.ts | 9 + .../models/quality-assurance-topic.model.ts | 58 + ...lity-assurance-source-data.service.spec.ts | 126 ++ .../quality-assurance-source-data.service.ts | 104 + ...ality-assurance-topic-data.service.spec.ts | 133 ++ .../quality-assurance-topic-data.service.ts | 101 + .../source/suggestion-source-data.service.ts | 96 + .../suggestions-source-data.service.spec.ts | 115 + .../suggestion-data.service.spec.ts | 173 ++ .../notifications/suggestions-data.service.ts | 229 ++ .../target/suggestion-target-data.service.ts | 141 ++ .../suggestions-target-data.service.spec.ts | 138 ++ src/app/core/shared/context.model.ts | 2 + src/app/core/shared/view-mode.model.ts | 1 + .../submission/correctiontype-data.service.ts | 89 + .../submission/models/correctiontype.model.ts | 49 + .../workspaceitem-section-duplicates.model.ts | 8 + .../workspaceitem-section-upload.model.ts | 5 +- .../models/workspaceitem-sections.model.ts | 6 +- .../submission-duplicate-data.service.spec.ts | 30 + .../submission-duplicate-data.service.ts | 139 ++ .../vocabulary.data.service.spec.ts | 22 + .../vocabularies/vocabulary.data.service.ts | 25 + .../vocabularies/vocabulary.service.spec.ts | 21 + .../vocabularies/vocabulary.service.ts | 17 + .../workflowitem-data.service.spec.ts | 2 +- .../workspaceitem-data.service.spec.ts | 54 +- .../submission/workspaceitem-data.service.ts | 110 +- .../supervision-order-data.service.spec.ts | 5 +- src/app/home-page/home-page.module.ts | 5 +- src/app/info/info-routing.module.ts | 24 +- src/app/info/info.module.ts | 4 +- .../full/full-item-page.component.spec.ts | 15 +- .../full/full-item-page.component.ts | 4 +- src/app/item-page/item-page.module.ts | 6 + src/app/item-page/item-shared.module.ts | 4 + .../item-page/simple/item-page.component.html | 2 + .../simple/item-page.component.spec.ts | 22 +- .../item-page/simple/item-page.component.ts | 63 +- .../publication/publication.component.html | 16 + .../notify-requests-status.component.html | 5 + .../notify-requests-status.component.spec.ts | 88 + .../notify-requests-status.component.ts | 77 + .../notify-requests-status.model.ts | 72 + .../notify-requests-status.resource-type.ts | 8 + .../notify-status.enum.ts | 5 + .../request-status-alert-box.component.html | 33 + .../request-status-alert-box.component.scss | 7 + ...request-status-alert-box.component.spec.ts | 53 + .../request-status-alert-box.component.ts | 82 + .../qa-event-notification.component.html | 22 + .../qa-event-notification.component.scss | 13 + .../qa-event-notification.component.spec.ts | 73 + .../qa-event-notification.component.ts | 76 + src/app/menu.resolver.spec.ts | 7 + src/app/menu.resolver.ts | 89 +- .../my-dspace-page.component.html | 2 + .../my-dspace-page/my-dspace-page.module.ts | 8 +- ...ace-qa-events-notifications.component.html | 29 + ...ace-qa-events-notifications.component.scss | 13 + ...-qa-events-notifications.component.spec.ts | 36 + ...space-qa-events-notifications.component.ts | 53 + .../notifications/notifications-effects.ts | 9 + .../notifications-state.service.spec.ts | 541 +++++ .../notifications-state.service.ts | 212 ++ src/app/notifications/notifications.module.ts | 113 + .../notifications/notifications.reducer.ts | 21 + .../ePerson-data/ePerson-data.component.html | 10 + .../ePerson-data.component.spec.ts | 58 + .../ePerson-data/ePerson-data.component.ts | 44 + .../quality-assurance-events.component.html | 287 +++ .../quality-assurance-events.component.scss | 28 + ...quality-assurance-events.component.spec.ts | 344 +++ .../quality-assurance-events.component.ts | 516 +++++ .../project-entry-import-modal.component.html | 71 + .../project-entry-import-modal.component.scss | 3 + ...oject-entry-import-modal.component.spec.ts | 210 ++ .../project-entry-import-modal.component.ts | 278 +++ .../quality-assurance-source.actions.ts | 98 + .../quality-assurance-source.component.html | 58 + ...quality-assurance-source.component.spec.ts | 152 ++ .../quality-assurance-source.component.ts | 141 ++ .../quality-assurance-source.effects.ts | 93 + .../quality-assurance-source.reducer.spec.ts | 68 + .../quality-assurance-source.reducer.ts | 72 + .../quality-assurance-source.service.spec.ts | 69 + .../quality-assurance-source.service.ts | 63 + .../quality-assurance-topics.actions.ts | 102 + .../quality-assurance-topics.component.html | 61 + ...quality-assurance-topics.component.spec.ts | 160 ++ .../quality-assurance-topics.component.ts | 222 ++ .../quality-assurance-topics.effects.ts | 94 + .../quality-assurance-topics.reducer.spec.ts | 68 + .../quality-assurance-topics.reducer.ts | 72 + .../quality-assurance-topics.service.spec.ts | 72 + .../quality-assurance-topics.service.ts | 72 + src/app/notifications/selectors.ts | 149 ++ .../suggestion-actions.component.html | 29 + .../suggestion-actions.component.scss | 1 + .../suggestion-actions.component.ts | 96 + .../suggestion-evidences.component.html | 20 + .../suggestion-evidences.component.ts | 18 + .../suggestion-list-element.component.html | 45 + .../suggestion-list-element.component.scss | 16 + .../suggestion-list-element.component.spec.ts | 81 + .../suggestion-list-element.component.ts | 105 + .../publication-claim.component.html | 51 + .../publication-claim.component.ts | 143 ++ .../suggestion-targets.actions.ts | 157 ++ .../suggestion-targets.effects.ts | 98 + .../suggestion-targets.reducer.ts | 101 + .../suggestion-targets.state.service.ts | 164 ++ .../notifications/suggestion.service.spec.ts | 176 ++ .../suggestions-notification.component.html | 9 + .../suggestions-notification.component.scss | 0 .../suggestions-notification.component.ts | 41 + .../suggestions-popup.component.html | 27 + .../suggestions-popup.component.scss | 0 .../suggestions-popup.component.spec.ts | 77 + .../suggestions-popup.component.ts | 82 + src/app/notifications/suggestions.service.ts | 303 +++ .../profile-page/profile-page.component.html | 1 + src/app/profile-page/profile-page.module.ts | 11 +- .../notifications-pages-routing-paths.ts | 7 + .../notifications-pages-routing.module.ts | 122 ++ .../notifications-pages.module.ts | 37 + ...uggestion-targets-page-resolver.service.ts | 35 + ...ons-suggestion-targets-page.component.html | 1 + ...ons-suggestion-targets-page.component.scss | 0 ...-suggestion-targets-page.component.spec.ts | 41 + ...tions-suggestion-targets-page.component.ts | 10 + ...ality-assurance-events-page.component.html | 1 + ...ty-assurance-events-page.component.spec.ts | 26 + ...quality-assurance-events-page.component.ts | 12 + .../quality-assurance-events-page.resolver.ts | 32 + .../quality-assurance-source-data.resolver.ts | 47 + ...-assurance-source-page-resolver.service.ts | 32 + ...ality-assurance-source-page.component.html | 1 + ...ty-assurance-source-page.component.spec.ts | 27 + ...quality-assurance-source-page.component.ts | 10 + ...-assurance-topics-page-resolver.service.ts | 32 + ...ality-assurance-topics-page.component.html | 1 + ...ty-assurance-topics-page.component.spec.ts | 26 + ...quality-assurance-topics-page.component.ts | 12 + .../abstract-component-loader.component.html | 1 + .../abstract-component-loader.component.ts | 143 ++ .../dynamic-component-loader.directive.ts | 16 + ...m-withdrawn-reinstate-modal.component.html | 55 + ...m-withdrawn-reinstate-modal.component.scss | 0 .../withdrawn-reinstate-modal.component.ts | 74 + .../dso-page/dso-edit-menu.resolver.spec.ts | 26 +- .../shared/dso-page/dso-edit-menu.resolver.ts | 49 +- .../dso-withdrawn-reinstate-modal.service.ts | 102 + src/app/shared/mocks/active-router.mock.ts | 19 +- src/app/shared/mocks/notifications.mock.ts | 1883 +++++++++++++++++ .../mocks/publication-claim-targets.mock.ts | 42 + .../shared/mocks/publication-claim.mock.ts | 211 ++ .../mocks/section-upload.service.mock.ts | 3 + src/app/shared/mocks/submission.mock.ts | 11 +- src/app/shared/mocks/suggestion.mock.ts | 1360 ++++++++++++ .../notification-box.component.html | 12 + .../notification-box.component.scss | 8 + .../notification-box.component.spec.ts | 38 + .../notification-box.component.ts | 28 + .../notification/notification.component.scss | 3 +- .../object-collection.component.html | 16 + ...ble-object-component-loader.component.html | 1 - ...-object-component-loader.component.spec.ts | 24 +- ...table-object-component-loader.component.ts | 152 +- .../listable-object.decorator.ts | 4 +- ...bjects-collection-tabulatable.component.ts | 82 + ...electable-list-item-control.component.html | 4 +- ...ctable-list-item-control.component.spec.ts | 6 +- .../tabulatable-objects-loader.component.html | 1 + ...bulatable-objects-loader.component.spec.ts | 107 + .../tabulatable-objects-loader.component.ts | 208 ++ .../tabulatable-objects.decorator.spec.ts | 23 + .../tabulatable-objects.decorator.ts | 66 + .../tabulatable-objects.directive.ts | 11 + .../duplicate-data/duplicate.model.ts | 57 + .../duplicate-data/duplicate.resource-type.ts | 9 + ...-search-result-list-element.component.html | 12 +- ...arch-result-list-element.component.spec.ts | 25 +- ...ed-search-result-list-element.component.ts | 53 +- ...-search-result-list-element.component.html | 13 + ...arch-result-list-element.component.spec.ts | 26 +- ...ol-search-result-list-element.component.ts | 55 +- ...ulatable-result-list-elements.component.ts | 15 + .../collection-select.component.html | 4 +- .../item-select/item-select.component.html | 4 +- .../object-table/object-table.component.html | 33 + .../object-table/object-table.component.scss | 0 .../object-table.component.spec.ts | 152 ++ .../object-table/object-table.component.ts | 206 ++ .../shared/pagination/pagination.component.ts | 5 + src/app/shared/selector.util.ts | 27 + src/app/shared/shared.module.ts | 40 +- .../date/starts-with-date.component.spec.ts | 37 +- .../date/starts-with-date.component.ts | 22 +- .../starts-with-abstract.component.ts | 31 +- .../starts-with-loader.component.spec.ts | 71 + .../starts-with-loader.component.ts | 33 + .../text/starts-with-text.component.spec.ts | 27 +- .../text/starts-with-text.component.ts | 11 - .../testing/object-cache-service.stub.ts | 31 + src/app/shared/utils/ipV4.validator.spec.ts | 36 + src/app/shared/utils/ipV4.validator.ts | 26 + src/app/shared/utils/split.pipe.ts | 16 + .../submission-form-collection.component.html | 2 +- .../form/submission-form.component.html | 7 +- ...n-import-external-searchbar.component.html | 29 +- .../submission-import-external.component.html | 10 +- .../submission-import-external.component.ts | 3 + .../objects/submission-objects.actions.ts | 48 + .../objects/submission-objects.effects.ts | 84 +- .../submission-objects.reducer.spec.ts | 20 +- .../objects/submission-objects.reducer.ts | 70 +- .../section-container.component.html | 2 +- .../section-duplicates.component.html | 20 + .../section-duplicates.component.spec.ts | 248 +++ .../section-duplicates.component.ts | 124 ++ .../license/section-license.component.ts | 1 + .../coar-notify-config-data.service.spec.ts | 92 + .../coar-notify-config-data.service.ts | 113 + ...ction-coar-notify-service.resource-type.ts | 13 + .../section-coar-notify.component.html | 149 ++ .../section-coar-notify.component.scss | 5 + .../section-coar-notify.component.spec.ts | 443 ++++ .../section-coar-notify.component.ts | 340 +++ ...mission-coar-notify-workspaceitem.model.ts | 35 + .../submission-coar-notify.config.ts | 42 + src/app/submission/sections/sections-type.ts | 2 + .../section-upload-file-edit.component.html | 1 - ...section-upload-file-edit.component.spec.ts | 55 +- .../section-upload-file-edit.component.ts | 155 +- .../edit/section-upload-file-edit.model.ts | 14 + .../file/section-upload-file.component.html | 18 +- .../section-upload-file.component.spec.ts | 22 +- .../file/section-upload-file.component.ts | 40 +- .../themed-section-upload-file.component.ts | 8 + .../upload/section-upload.component.html | 38 +- .../upload/section-upload.component.spec.ts | 24 +- .../upload/section-upload.component.ts | 37 +- .../upload/section-upload.service.spec.ts | 63 + .../sections/upload/section-upload.service.ts | 69 +- src/app/submission/submission.module.ts | 45 +- src/app/suggestion-notifications/selectors.ts | 98 + .../suggestions-page-routing-paths.ts | 11 + .../suggestions-page-routing.module.ts | 36 + .../suggestions-page.component.html | 48 + .../suggestions-page.component.scss | 0 .../suggestions-page.component.spec.ts | 217 ++ .../suggestions-page.component.ts | 286 +++ .../suggestions-page.module.ts | 24 + .../suggestions-page.resolver.ts | 33 + .../workflowitems-edit-page-routing-paths.ts | 5 + .../workflowitems-edit-page.module.ts | 4 - src/assets/i18n/en.json5 | 935 ++++++++ src/assets/images/n-coar.png | Bin 0 -> 5564 bytes src/assets/images/qa-DSpaceUsers-logo.png | Bin 0 -> 8609 bytes src/assets/images/qa-coar-notify-logo.png | Bin 0 -> 31117 bytes src/assets/images/qa-openaire-logo.png | Bin 0 -> 31205 bytes src/config/app-config.interface.ts | 9 +- src/config/default-app-config.ts | 139 +- src/config/quality-assurance.config.ts | 17 + src/config/submission-config.interface.ts | 6 + src/config/suggestion-config.interfaces.ts | 6 + src/environments/environment.test.ts | 98 +- 399 files changed, 28216 insertions(+), 591 deletions(-) create mode 100644 src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts create mode 100644 src/app/admin/admin-ldn-services/admin-ldn-services.module.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html create mode 100644 src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss create mode 100644 src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html create mode 100644 src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss create mode 100644 src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts create mode 100644 src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts create mode 100644 src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts create mode 100644 src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html create mode 100644 src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts create mode 100644 src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts create mode 100644 src/app/admin/admin-notifications/admin-notifications-routing-paths.ts create mode 100644 src/app/admin/admin-notifications/admin-notifications-routing.module.ts create mode 100644 src/app/admin/admin-notifications/admin-notifications.module.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts create mode 100644 src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts create mode 100644 src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts create mode 100644 src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts create mode 100644 src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts create mode 100644 src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts create mode 100644 src/app/admin/admin-reports/admin-reports-routing.module.ts create mode 100644 src/app/admin/admin-reports/admin-reports.module.ts create mode 100644 src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts create mode 100644 src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html create mode 100644 src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss create mode 100644 src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts create mode 100644 src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts create mode 100644 src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts create mode 100644 src/app/admin/admin-reports/filtered-items/filtered-items-model.ts create mode 100644 src/app/admin/admin-reports/filtered-items/filtered-items.component.html create mode 100644 src/app/admin/admin-reports/filtered-items/filtered-items.component.scss create mode 100644 src/app/admin/admin-reports/filtered-items/filtered-items.component.ts create mode 100644 src/app/admin/admin-reports/filtered-items/option-vo.model.ts create mode 100644 src/app/admin/admin-reports/filtered-items/preset-query.model.ts create mode 100644 src/app/admin/admin-reports/filtered-items/query-predicate.model.ts create mode 100644 src/app/admin/admin-reports/filters-section/filter-group.model.ts create mode 100644 src/app/admin/admin-reports/filters-section/filter.model.ts create mode 100644 src/app/admin/admin-reports/filters-section/filters-section.component.html create mode 100644 src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts create mode 100644 src/app/admin/admin-reports/filters-section/filters-section.component.ts create mode 100644 src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html create mode 100644 src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts create mode 100644 src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts create mode 100644 src/app/core/breadcrumbs/navigation-breadcrumb.service.ts create mode 100644 src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts create mode 100644 src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts create mode 100644 src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts create mode 100644 src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts create mode 100644 src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts create mode 100644 src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts create mode 100644 src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts create mode 100644 src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts create mode 100644 src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts create mode 100644 src/app/core/coar-notify/notify-info/notify-info.component.html create mode 100644 src/app/core/coar-notify/notify-info/notify-info.component.spec.ts create mode 100644 src/app/core/coar-notify/notify-info/notify-info.component.ts create mode 100644 src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts create mode 100644 src/app/core/coar-notify/notify-info/notify-info.guard.ts create mode 100644 src/app/core/coar-notify/notify-info/notify-info.service.spec.ts create mode 100644 src/app/core/coar-notify/notify-info/notify-info.service.ts create mode 100644 src/app/core/data/notify-services-status-data.service.spec.ts create mode 100644 src/app/core/data/notify-services-status-data.service.ts create mode 100644 src/app/core/data/update-data.service.spec.ts create mode 100644 src/app/core/notifications/models/suggestion-objects.resource-type.ts create mode 100644 src/app/core/notifications/models/suggestion-source-object.resource-type.ts create mode 100644 src/app/core/notifications/models/suggestion-source.model.ts create mode 100644 src/app/core/notifications/models/suggestion-target-object.resource-type.ts create mode 100644 src/app/core/notifications/models/suggestion-target.model.ts create mode 100644 src/app/core/notifications/models/suggestion.model.ts create mode 100644 src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts create mode 100644 src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts create mode 100644 src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts create mode 100644 src/app/core/notifications/qa/models/quality-assurance-event.model.ts create mode 100644 src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts create mode 100644 src/app/core/notifications/qa/models/quality-assurance-source.model.ts create mode 100644 src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts create mode 100644 src/app/core/notifications/qa/models/quality-assurance-topic.model.ts create mode 100644 src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts create mode 100644 src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts create mode 100644 src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts create mode 100644 src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts create mode 100644 src/app/core/notifications/source/suggestion-source-data.service.ts create mode 100644 src/app/core/notifications/source/suggestions-source-data.service.spec.ts create mode 100644 src/app/core/notifications/suggestion-data.service.spec.ts create mode 100644 src/app/core/notifications/suggestions-data.service.ts create mode 100644 src/app/core/notifications/target/suggestion-target-data.service.ts create mode 100644 src/app/core/notifications/target/suggestions-target-data.service.spec.ts create mode 100644 src/app/core/submission/correctiontype-data.service.ts create mode 100644 src/app/core/submission/models/correctiontype.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-duplicates.model.ts create mode 100644 src/app/core/submission/submission-duplicate-data.service.spec.ts create mode 100644 src/app/core/submission/submission-duplicate-data.service.ts create mode 100644 src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.html create mode 100644 src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.spec.ts create mode 100644 src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts create mode 100644 src/app/item-page/simple/notify-requests-status/notify-requests-status.model.ts create mode 100644 src/app/item-page/simple/notify-requests-status/notify-requests-status.resource-type.ts create mode 100644 src/app/item-page/simple/notify-requests-status/notify-status.enum.ts create mode 100644 src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.html create mode 100644 src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.scss create mode 100644 src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.spec.ts create mode 100644 src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.ts create mode 100644 src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html create mode 100644 src/app/item-page/simple/qa-event-notification/qa-event-notification.component.scss create mode 100644 src/app/item-page/simple/qa-event-notification/qa-event-notification.component.spec.ts create mode 100644 src/app/item-page/simple/qa-event-notification/qa-event-notification.component.ts create mode 100644 src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.html create mode 100644 src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.scss create mode 100644 src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.spec.ts create mode 100644 src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.ts create mode 100644 src/app/notifications/notifications-effects.ts create mode 100644 src/app/notifications/notifications-state.service.spec.ts create mode 100644 src/app/notifications/notifications-state.service.ts create mode 100644 src/app/notifications/notifications.module.ts create mode 100644 src/app/notifications/notifications.reducer.ts create mode 100644 src/app/notifications/qa/events/ePerson-data/ePerson-data.component.html create mode 100644 src/app/notifications/qa/events/ePerson-data/ePerson-data.component.spec.ts create mode 100644 src/app/notifications/qa/events/ePerson-data/ePerson-data.component.ts create mode 100644 src/app/notifications/qa/events/quality-assurance-events.component.html create mode 100644 src/app/notifications/qa/events/quality-assurance-events.component.scss create mode 100644 src/app/notifications/qa/events/quality-assurance-events.component.spec.ts create mode 100644 src/app/notifications/qa/events/quality-assurance-events.component.ts create mode 100644 src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html create mode 100644 src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss create mode 100644 src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts create mode 100644 src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.actions.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.component.html create mode 100644 src/app/notifications/qa/source/quality-assurance-source.component.spec.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.component.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.effects.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.reducer.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.service.spec.ts create mode 100644 src/app/notifications/qa/source/quality-assurance-source.service.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.actions.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.component.html create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.component.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.effects.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts create mode 100644 src/app/notifications/qa/topics/quality-assurance-topics.service.ts create mode 100644 src/app/notifications/selectors.ts create mode 100644 src/app/notifications/suggestion-actions/suggestion-actions.component.html create mode 100644 src/app/notifications/suggestion-actions/suggestion-actions.component.scss create mode 100644 src/app/notifications/suggestion-actions/suggestion-actions.component.ts create mode 100644 src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html create mode 100644 src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts create mode 100644 src/app/notifications/suggestion-list-element/suggestion-list-element.component.html create mode 100644 src/app/notifications/suggestion-list-element/suggestion-list-element.component.scss create mode 100644 src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts create mode 100644 src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts create mode 100644 src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html create mode 100644 src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts create mode 100644 src/app/notifications/suggestion-targets/suggestion-targets.actions.ts create mode 100644 src/app/notifications/suggestion-targets/suggestion-targets.effects.ts create mode 100644 src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts create mode 100644 src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts create mode 100644 src/app/notifications/suggestion.service.spec.ts create mode 100644 src/app/notifications/suggestions-notification/suggestions-notification.component.html create mode 100644 src/app/notifications/suggestions-notification/suggestions-notification.component.scss create mode 100644 src/app/notifications/suggestions-notification/suggestions-notification.component.ts create mode 100644 src/app/notifications/suggestions-popup/suggestions-popup.component.html create mode 100644 src/app/notifications/suggestions-popup/suggestions-popup.component.scss create mode 100644 src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts create mode 100644 src/app/notifications/suggestions-popup/suggestions-popup.component.ts create mode 100644 src/app/notifications/suggestions.service.ts create mode 100644 src/app/quality-assurance-notifications-pages/notifications-pages-routing-paths.ts create mode 100644 src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts create mode 100644 src/app/quality-assurance-notifications-pages/notifications-pages.module.ts create mode 100644 src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts create mode 100644 src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.html create mode 100644 src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.scss create mode 100644 src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts create mode 100644 src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.html create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.spec.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.html create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.spec.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.html create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.spec.ts create mode 100644 src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.ts create mode 100644 src/app/shared/abstract-component-loader/abstract-component-loader.component.html create mode 100644 src/app/shared/abstract-component-loader/abstract-component-loader.component.ts create mode 100644 src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts create mode 100644 src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.html create mode 100644 src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.scss create mode 100644 src/app/shared/correction-suggestion/withdrawn-reinstate-modal.component.ts create mode 100644 src/app/shared/dso-page/dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service.ts create mode 100644 src/app/shared/mocks/notifications.mock.ts create mode 100644 src/app/shared/mocks/publication-claim-targets.mock.ts create mode 100644 src/app/shared/mocks/publication-claim.mock.ts create mode 100644 src/app/shared/mocks/suggestion.mock.ts create mode 100644 src/app/shared/notification-box/notification-box.component.html create mode 100644 src/app/shared/notification-box/notification-box.component.scss create mode 100644 src/app/shared/notification-box/notification-box.component.spec.ts create mode 100644 src/app/shared/notification-box/notification-box.component.ts delete mode 100644 src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.html create mode 100644 src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts create mode 100644 src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html create mode 100644 src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts create mode 100644 src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts create mode 100644 src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.spec.ts create mode 100644 src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts create mode 100644 src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts create mode 100644 src/app/shared/object-list/duplicate-data/duplicate.model.ts create mode 100644 src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts create mode 100644 src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts create mode 100644 src/app/shared/object-table/object-table.component.html create mode 100644 src/app/shared/object-table/object-table.component.scss create mode 100644 src/app/shared/object-table/object-table.component.spec.ts create mode 100644 src/app/shared/object-table/object-table.component.ts create mode 100644 src/app/shared/selector.util.ts create mode 100644 src/app/shared/starts-with/starts-with-loader.component.spec.ts create mode 100644 src/app/shared/starts-with/starts-with-loader.component.ts create mode 100644 src/app/shared/testing/object-cache-service.stub.ts create mode 100644 src/app/shared/utils/ipV4.validator.spec.ts create mode 100644 src/app/shared/utils/ipV4.validator.ts create mode 100644 src/app/shared/utils/split.pipe.ts create mode 100644 src/app/submission/sections/duplicates/section-duplicates.component.html create mode 100644 src/app/submission/sections/duplicates/section-duplicates.component.spec.ts create mode 100644 src/app/submission/sections/duplicates/section-duplicates.component.ts create mode 100644 src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts create mode 100644 src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts create mode 100644 src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts create mode 100644 src/app/submission/sections/section-coar-notify/section-coar-notify.component.html create mode 100644 src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss create mode 100644 src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts create mode 100644 src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts create mode 100644 src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts create mode 100644 src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts create mode 100644 src/app/submission/sections/upload/section-upload.service.spec.ts create mode 100644 src/app/suggestion-notifications/selectors.ts create mode 100644 src/app/suggestions-page/suggestions-page-routing-paths.ts create mode 100644 src/app/suggestions-page/suggestions-page-routing.module.ts create mode 100644 src/app/suggestions-page/suggestions-page.component.html create mode 100644 src/app/suggestions-page/suggestions-page.component.scss create mode 100644 src/app/suggestions-page/suggestions-page.component.spec.ts create mode 100644 src/app/suggestions-page/suggestions-page.component.ts create mode 100644 src/app/suggestions-page/suggestions-page.module.ts create mode 100644 src/app/suggestions-page/suggestions-page.resolver.ts create mode 100644 src/assets/images/n-coar.png create mode 100644 src/assets/images/qa-DSpaceUsers-logo.png create mode 100644 src/assets/images/qa-coar-notify-logo.png create mode 100644 src/assets/images/qa-openaire-logo.png create mode 100644 src/config/quality-assurance.config.ts create mode 100644 src/config/suggestion-config.interfaces.ts diff --git a/cypress.config.ts b/cypress.config.ts index 91eeb9838b3..f6f7a040735 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -40,5 +40,6 @@ export default defineConfig({ // It can be overridden via the CYPRESS_BASE_URL environment variable // (By default we set this to a value which should work in most development environments) baseUrl: 'http://localhost:4000', + experimentalRunAllSpecs: true }, }); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index ead38afb921..cc3dccba38e 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -1,5 +1,11 @@ const fs = require('fs'); +// These two global variables are used to store information about the REST API used +// by these e2e tests. They are filled out prior to running any tests in the before() +// method of e2e.ts. They can then be accessed by any tests via the getters below. +let REST_BASE_URL: string; +let REST_DOMAIN: string; + // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // For more info, visit https://on.cypress.io/plugins-api module.exports = (on, config) => { @@ -30,6 +36,24 @@ module.exports = (on, config) => { } return null; + }, + // Save value of REST Base URL, looked up before all tests. + // This allows other tests to use it easily via getRestBaseURL() below. + saveRestBaseURL(url: string) { + return (REST_BASE_URL = url); + }, + // Retrieve currently saved value of REST Base URL + getRestBaseURL() { + return REST_BASE_URL ; + }, + // Save value of REST Domain, looked up before all tests. + // This allows other tests to use it easily via getRestBaseDomain() below. + saveRestBaseDomain(domain: string) { + return (REST_DOMAIN = domain); + }, + // Retrieve currently saved value of REST Domain + getRestBaseDomain() { + return REST_DOMAIN ; } }); }; diff --git a/src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts b/src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts new file mode 100644 index 00000000000..c94083d3ba6 --- /dev/null +++ b/src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts @@ -0,0 +1,47 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { LdnServicesOverviewComponent } from './ldn-services-directory/ldn-services-directory.component'; +import { NavigationBreadcrumbResolver } from '../../core/breadcrumbs/navigation-breadcrumb.resolver'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { LdnServiceFormComponent } from './ldn-service-form/ldn-service-form.component'; + + +const moduleRoutes: Routes = [ + { + path: '', + pathMatch: 'full', + component: LdnServicesOverviewComponent, + resolve: {breadcrumb: I18nBreadcrumbResolver}, + data: {title: 'ldn-registered-services.title', breadcrumbKey: 'ldn-registered-services.new'}, + }, + { + path: 'new', + resolve: {breadcrumb: NavigationBreadcrumbResolver}, + component: LdnServiceFormComponent, + data: {title: 'ldn-register-new-service.title', breadcrumbKey: 'ldn-register-new-service'} + }, + { + path: 'edit/:serviceId', + resolve: {breadcrumb: NavigationBreadcrumbResolver}, + component: LdnServiceFormComponent, + data: {title: 'ldn-edit-service.title', breadcrumbKey: 'ldn-edit-service'} + }, +]; + + +@NgModule({ + imports: [ + RouterModule.forChild(moduleRoutes.map(route => { + 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..0a7bc39fa31 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html @@ -0,0 +1,315 @@ +
+
+
+

{{ 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.error.ldnurl.ldnUrlAlreadyAssociated' | 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..93f7911057c --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts @@ -0,0 +1,578 @@ +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 { + if (!this.formModel.errors) { + this.setLdnUrlError(); + } + 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 { + if (!this.formModel.errors) { + this.setLdnUrlError(); + } + 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: '', + }); + } + + + /** + * set ldnUrl error in case of unprocessable entity and provided value + */ + private setLdnUrlError(): void { + const control = this.formModel.controls.ldnUrl; + const controlErrors = control.errors || {}; + control.setErrors({...controlErrors, ldnUrlAlreadyAssociated: true }); + } +} 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..c661a034e4d --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts @@ -0,0 +1,131 @@ +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), + buildFromRequestUUID: 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(); + }); + }); + + it('should invoke service', (done) => { + const constraints = [{void: true}]; + const files = [new File([],'fileName')]; + spyOn(service as any, 'getInvocationFormData'); + spyOn(service, 'getBrowseEndpoint').and.returnValue(observableOf('testEndpoint')); + service.invoke('serviceName', 'serviceId', constraints, files).subscribe(result => { + expect((service as any).getInvocationFormData).toHaveBeenCalledWith(constraints, files); + expect(service.getBrowseEndpoint).toHaveBeenCalled(); + expect(result).toBeInstanceOf(RemoteData); + 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..ddb7a9fbb99 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts @@ -0,0 +1,163 @@ +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import { ChangeDetectorRef, EventEmitter, NO_ERRORS_SCHEMA } 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; + + 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': createSuccessfulRemoteDataObject$({}), + 'delete': createSuccessfulRemoteDataObject$({}), + 'patch': createSuccessfulRemoteDataObject$({}), + }); + 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: new NotificationsServiceStub()}, + {provide: TranslateService, useValue: translateServiceStub}, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LdnServicesOverviewComponent); + component = fixture.componentInstance; + ldnServicesService = TestBed.inject(LdnServicesService); + paginationService = TestBed.inject(PaginationService); + modalService = TestBed.inject(NgbModal); + 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); + })); + }); + + describe('selectServiceToDelete', () => { + it('should set service to delete', fakeAsync(() => { + spyOn(component, 'openDeleteModal'); + const serviceId = 123; + component.selectServiceToDelete(serviceId); + expect(component.selectedServiceId).toEqual(serviceId); + expect(component.openDeleteModal).toHaveBeenCalled(); + })); + }); + + describe('toggleStatus', () => { + it('should toggle status', (() => { + component.toggleStatus({enabled: false}, ldnServicesService); + expect(ldnServicesService.patch).toHaveBeenCalled(); + })); + }); + +}); 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/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..e16d7e51e4d --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-admin-notifications-publication-claim-page', + templateUrl: './admin-notifications-publication-claim-page.component.html' +}) +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 new file mode 100644 index 00000000000..9fcabedd647 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -0,0 +1,7 @@ + +export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const PUBLICATION_CLAIMS_PATH = 'publication-claim'; + +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 new file mode 100644 index 00000000000..c82c91233ee --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -0,0 +1,140 @@ +import { NgModule } from '@angular/core'; +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 { + 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 '../../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: QualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + 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: QualityAssuranceSourcePageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceSourceParams: QualityAssuranceSourcePageResolver, + sourceData: SourceDataResolver + }, + data: { + title: 'admin.notifications.source.breadcrumbs', + breadcrumbKey: 'admin.notifications.source', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, + component: QualityAssuranceEventsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceEventsParams: QualityAssuranceEventsPageResolver + }, + data: { + title: 'admin.notifications.event.page.title', + breadcrumbKey: 'admin.notifications.event', + showBreadcrumbsFluid: false + } + } + ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + AdminNotificationsPublicationClaimPageResolver, + SourceDataResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceTopicsPageResolver, + QualityAssuranceEventsPageResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceBreadcrumbResolver, + QualityAssuranceBreadcrumbService + ] +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class AdminNotificationsRoutingModule { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts new file mode 100644 index 00000000000..ea670d222c0 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +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 { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { NotificationsModule } from '../../notifications/notifications.module'; + + + + + + + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + AdminNotificationsRoutingModule, + NotificationsModule + ], + declarations: [ + AdminNotificationsPublicationClaimPageComponent, + ], + entryComponents: [] +}) +/** + * This module handles all components related to the notifications pages + */ +export class AdminNotificationsModule { + +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts new file mode 100644 index 00000000000..6fb3b469777 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts @@ -0,0 +1,64 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { + SiteAdministratorGuard +} from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + AdminNotifyIncomingComponent +} from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component'; +import { + AdminNotifyOutgoingComponent +} from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component'; +import { NotifyInfoGuard } from '../../core/coar-notify/notify-info/notify-info.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [SiteAdministratorGuard, NotifyInfoGuard], + path: '', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + component: AdminNotifyDashboardComponent, + pathMatch: 'full', + data: { + title: 'admin.notify.dashboard.page.title', + breadcrumbKey: 'admin.notify.dashboard', + }, + }, + { + path: 'inbound', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + component: AdminNotifyIncomingComponent, + canActivate: [SiteAdministratorGuard, NotifyInfoGuard], + data: { + title: 'admin.notify.dashboard.page.title', + breadcrumbKey: 'admin.notify.dashboard', + }, + }, + { + path: 'outbound', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + component: AdminNotifyOutgoingComponent, + canActivate: [SiteAdministratorGuard, NotifyInfoGuard], + data: { + title: 'admin.notify.dashboard.page.title', + breadcrumbKey: 'admin.notify.dashboard', + }, + } + ]) + ], +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class AdminNotifyDashboardRoutingModule { + +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html new file mode 100644 index 00000000000..3adb7e857b6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html @@ -0,0 +1,23 @@ +
+
+
+

{{'admin-notify-dashboard.title'| translate}}

+
{{'admin-notify-dashboard.description' | translate}}
+ +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts new file mode 100644 index 00000000000..74007055b48 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { SearchService } from '../../core/shared/search/search.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { AdminNotifySearchResult } from './models/admin-notify-message-search-result.model'; +import { AdminNotifyMessage } from './models/admin-notify-message.model'; + +describe('AdminNotifyDashboardComponent', () => { + let component: AdminNotifyDashboardComponent; + let fixture: ComponentFixture; + + let item1; + let item2; + let item3; + let searchResult1; + let searchResult2; + let searchResult3; + let results; + + const mockBoxes = [ + { title: 'admin-notify-dashboard.received-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] }, + { title: 'admin-notify-dashboard.generated-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] } + ]; + + beforeEach(async () => { + item1 = Object.assign(new AdminNotifyMessage(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new AdminNotifyMessage(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + item3 = Object.assign(new AdminNotifyMessage(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + searchResult1 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item2 }); + searchResult3 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item3 }); + results = buildPaginatedList(undefined, [searchResult1, searchResult2, searchResult3]); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NgbNavModule], + declarations: [ AdminNotifyDashboardComponent ], + providers: [{ provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results)}}] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyDashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', (done) => { + component.notifyMetricsRows$.subscribe(boxes => { + expect(boxes).toEqual(mockBoxes); + done(); + }); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts new file mode 100644 index 00000000000..9aa738b29bd --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts @@ -0,0 +1,95 @@ +import { Component, OnInit } from '@angular/core'; +import { SearchService } from '../../core/shared/search/search.service'; +import { environment } from '../../../environments/environment'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { forkJoin, Observable } from 'rxjs'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { map } from 'rxjs/operators'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; +import { AdminNotifyMetricsBox, AdminNotifyMetricsRow } from './admin-notify-metrics/admin-notify-metrics.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-admin-notify-dashboard', + templateUrl: './admin-notify-dashboard.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Component used for visual representation and search of LDN messages for Admins + */ +export class AdminNotifyDashboardComponent implements OnInit{ + + public notifyMetricsRows$: Observable; + + private metricsConfig = environment.notifyMetrics; + private singleResultOptions = Object.assign(new PaginationComponentOptions(), { + id: 'single-result-options', + pageSize: 1 + }); + + constructor(private searchService: SearchService) { + } + + ngOnInit() { + const mertricsRowsConfigurations = this.metricsConfig + .map(row => row.boxes) + .map(boxes => boxes.map(box => box.config).filter(config => !!config)); + const flatConfigurations = [].concat(...mertricsRowsConfigurations.map((config) => config)); + const searchConfigurations = flatConfigurations + .map(config => Object.assign(new PaginatedSearchOptions({}), + { configuration: config, pagination: this.singleResultOptions } + )); + + this.notifyMetricsRows$ = forkJoin(searchConfigurations.map(config => this.searchService.search(config) + .pipe( + getFirstCompletedRemoteData(), + map(response => this.mapSearchObjectsToMetricsBox(response.payload)), + ) + ) + ).pipe( + map(metricBoxes => this.mapUpdatedBoxesToMetricsRows(metricBoxes)) + ); + } + + /** + * Function to map received SearchObjects to notify boxes config + * + * @param searchObject The object to map + * @private + */ + private mapSearchObjectsToMetricsBox(searchObject: SearchObjects): AdminNotifyMetricsBox { + const count = searchObject.pageInfo.totalElements; + const objectConfig = searchObject.configuration; + const metricsBoxes = [].concat(...this.metricsConfig.map((config) => config.boxes)); + + return { + ...metricsBoxes.find(box => box.config === objectConfig), + count + }; + } + + /** + * Function to map updated boxes with count to each row of the configuration + * + * @param boxesWithCount The object to map + * @private + */ + private mapUpdatedBoxesToMetricsRows(boxesWithCount: AdminNotifyMetricsBox[]): AdminNotifyMetricsRow[] { + return this.metricsConfig.map(row => { + return { + ...row, + boxes: row.boxes.map(rowBox => boxesWithCount.find(boxWithCount => boxWithCount.config === rowBox.config)) + }; + }); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts new file mode 100644 index 00000000000..0598fc33043 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts @@ -0,0 +1,56 @@ +import { NgModule } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { AdminNotifyDashboardRoutingModule } from './admin-notify-dashboard-routing.module'; +import { AdminNotifyMetricsComponent } from './admin-notify-metrics/admin-notify-metrics.component'; +import { AdminNotifyIncomingComponent } from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component'; +import { SharedModule } from '../../shared/shared.module'; +import { SearchModule } from '../../shared/search/search.module'; +import { SearchPageModule } from '../../search-page/search-page.module'; +import { + AdminNotifyOutgoingComponent +} from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component'; +import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal/admin-notify-detail-modal.component'; +import { + AdminNotifySearchResultComponent +} from './admin-notify-search-result/admin-notify-search-result.component'; +import { AdminNotifyMessagesService } from './services/admin-notify-messages.service'; +import { AdminNotifyLogsResultComponent } from './admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component'; + + +const ENTRY_COMPONENTS = [ + AdminNotifySearchResultComponent +]; +@NgModule({ + imports: [ + CommonModule, + RouterModule, + SharedModule, + AdminNotifyDashboardRoutingModule, + SearchModule, + SearchPageModule + ], + providers: [ + AdminNotifyMessagesService, + DatePipe + ], + declarations: [ + ...ENTRY_COMPONENTS, + AdminNotifyDashboardComponent, + AdminNotifyMetricsComponent, + AdminNotifyIncomingComponent, + AdminNotifyOutgoingComponent, + AdminNotifyDetailModalComponent, + AdminNotifySearchResultComponent, + AdminNotifyLogsResultComponent + ] +}) +export class AdminNotifyDashboardModule { + static withEntryComponents() { + return { + ngModule: AdminNotifyDashboardModule, + providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) + }; + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html new file mode 100644 index 00000000000..52d93cbb627 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html @@ -0,0 +1,22 @@ + + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts new file mode 100644 index 00000000000..0ddf449e5c7 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('AdminNotifyDetailModalComponent', () => { + let component: AdminNotifyDetailModalComponent; + let fixture: ComponentFixture; + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyDetailModalComponent ], + providers: [{ provide: NgbActiveModal, useValue: modalStub }] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyDetailModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close', () => { + spyOn(component.response, 'emit'); + component.closeModal(); + expect(modalStub.close).toHaveBeenCalled(); + expect(component.response.emit).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts new file mode 100644 index 00000000000..54b14be64c6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts @@ -0,0 +1,49 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { MissingTranslationHelper } from '../../../shared/translate/missing-translation.helper'; +import { fadeIn } from '../../../shared/animations/fade'; + +@Component({ + selector: 'ds-admin-notify-detail-modal', + templateUrl: './admin-notify-detail-modal.component.html', + animations: [ + fadeIn + ] +}) +/** + * Component for detailed view of LDN messages displayed in search result in AdminNotifyDashboardComponent + */ + +export class AdminNotifyDetailModalComponent { + @Input() notifyMessage: AdminNotifyMessage; + @Input() notifyMessageKeys: string[]; + + /** + * An event fired when the modal is closed + */ + @Output() + response = new EventEmitter(); + + public isCoarMessageVisible = false; + + + constructor(protected activeModal: NgbActiveModal, + public translationsService: TranslateService) { + this.translationsService.missingTranslationHandler = new MissingTranslationHelper(); + } + + + /** + * Close the modal and set the response to true so RootComponent knows the modal was closed + */ + closeModal() { + this.activeModal.close(); + this.response.emit(true); + } + + toggleCoarMessage() { + this.isCoarMessageVisible = !this.isCoarMessageVisible; + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html new file mode 100644 index 00000000000..4c957ca6300 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html @@ -0,0 +1,23 @@ + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts new file mode 100644 index 00000000000..f7388c5210d --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyIncomingComponent } from './admin-notify-incoming.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + +describe('AdminNotifyIncomingComponent', () => { + let component: AdminNotifyIncomingComponent; + let fixture: ComponentFixture; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + + + + beforeEach(async () => { + rdbService = getMockRemoteDataBuildService(); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': 'client/1234', + 'send': '', + }); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyIncomingComponent ], + providers: [ + { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyIncomingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts new file mode 100644 index 00000000000..b259d9a13cd --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts @@ -0,0 +1,19 @@ +import { Component, Inject } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-admin-notify-incoming', + templateUrl: './admin-notify-incoming.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class AdminNotifyIncomingComponent { + constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html new file mode 100644 index 00000000000..c26c2682e59 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html @@ -0,0 +1,25 @@ +
+
+
{{((isInbound$ | async) ? 'admin.notify.dashboard.inbound' : 'admin.notify.dashboard.outbound') | translate}}
+
+
+ +
+ +
+
+
+ + +
+ +
+ diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts new file mode 100644 index 00000000000..e59a52198d6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyLogsResultComponent } from './admin-notify-logs-result.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterStub } from '../../../../shared/testing/router.stub'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; + +describe('AdminNotifyLogsResultComponent', () => { + let component: AdminNotifyLogsResultComponent; + let fixture: ComponentFixture; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + let rdbService: RemoteDataBuildService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyLogsResultComponent ], + providers: [ + { provide: RouteService, useValue: routeServiceStub }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyLogsResultComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts new file mode 100644 index 00000000000..4f0407ce887 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts @@ -0,0 +1,79 @@ +import { + ChangeDetectorRef, + Component, + Inject, + Input, + OnInit +} from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { Context } from '../../../../core/shared/context.model'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { Observable } from 'rxjs'; +import { ActivatedRoute, ActivatedRouteSnapshot, Router } from '@angular/router'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'ds-admin-notify-logs-result', + templateUrl: './admin-notify-logs-result.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Component for visualization of search page and related results for the logs of the Notify dashboard + */ + +export class AdminNotifyLogsResultComponent implements OnInit { + + @Input() + defaultConfiguration: string; + + + public selectedSearchConfig$: Observable; + public isInbound$: Observable; + + protected readonly context = Context.CoarNotify; + + constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + private router: Router, + private route: ActivatedRoute, + protected cdRef: ChangeDetectorRef) { + } + + ngOnInit() { + this.selectedSearchConfig$ = this.searchConfigService.getCurrentConfiguration(this.defaultConfiguration); + this.isInbound$ = this.selectedSearchConfig$.pipe( + map(config => config.startsWith('NOTIFY.incoming')) + ); + } + + /** + * Reset route state to default configuration + */ + public resetDefaultConfiguration() { + //Idle navigation to trigger rendering of result on same page + this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { + this.router.navigate([this.getResolvedUrl(this.route.snapshot)], { + queryParams: { + configuration: this.defaultConfiguration, + view: ViewMode.Table, + }, + }); + }); + } + + /** + * Get resolved url from route + * + * @param route url path + * @returns url path + */ + private getResolvedUrl(route: ActivatedRouteSnapshot): string { + return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/'); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html new file mode 100644 index 00000000000..e9bc1d10b20 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html @@ -0,0 +1,24 @@ + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts new file mode 100644 index 00000000000..a8af9a7fd65 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyOutgoingComponent } from './admin-notify-outgoing.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + +describe('AdminNotifyOutgoingComponent', () => { + let component: AdminNotifyOutgoingComponent; + let fixture: ComponentFixture; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + + + beforeEach(async () => { + rdbService = getMockRemoteDataBuildService(); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': 'client/1234', + 'send': '', + }); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyOutgoingComponent ], + providers: [ + { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyOutgoingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts new file mode 100644 index 00000000000..a37ddc3bd6e --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts @@ -0,0 +1,19 @@ +import { Component, Inject } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-admin-notify-outgoing', + templateUrl: './admin-notify-outgoing.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class AdminNotifyOutgoingComponent { + constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html new file mode 100644 index 00000000000..3257bdd5ba9 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html @@ -0,0 +1,9 @@ +
+
{{ row.title | translate }}
+
+
+ +
+
+
+ diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts new file mode 100644 index 00000000000..57f21a4ef31 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyMetricsComponent } from './admin-notify-metrics.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { RouterStub } from '../../../shared/testing/router.stub'; + +describe('AdminNotifyMetricsComponent', () => { + let component: AdminNotifyMetricsComponent; + let fixture: ComponentFixture; + let router: RouterStub; + + beforeEach(async () => { + router = Object.assign(new RouterStub(), + {url : '/notify-dashboard'} + ); + + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyMetricsComponent ], + providers: [{provide: Router, useValue: router}] + }) + .compileComponents(); + + + + fixture = TestBed.createComponent(AdminNotifyMetricsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + it('should navigate to correct url based on config', () => { + const searchConfig = 'test.involvedItems'; + const incomingConfig = 'NOTIFY.incoming.test'; + const outgoingConfig = 'NOTIFY.outgoing.test'; + const adminPath = '/admin/search'; + const routeExtras = { + queryParams: { + configuration: searchConfig, + view: ViewMode.ListElement + }, + }; + + const routeExtrasTable = { + queryParams: { + configuration: incomingConfig, + view: ViewMode.Table + }, + }; + + const routeExtrasTableOutgoing = { + queryParams: { + configuration: outgoingConfig, + view: ViewMode.Table + }, + }; + component.navigateToSelectedSearchConfig(searchConfig); + expect(router.navigate).toHaveBeenCalledWith([adminPath], routeExtras); + + component.navigateToSelectedSearchConfig(incomingConfig); + expect(router.navigate).toHaveBeenCalledWith(['/notify-dashboard/inbound'], routeExtrasTable); + + component.navigateToSelectedSearchConfig(outgoingConfig); + expect(router.navigate).toHaveBeenCalledWith(['/notify-dashboard/outbound'], routeExtrasTableOutgoing); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts new file mode 100644 index 00000000000..8822e2bd1e8 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts @@ -0,0 +1,53 @@ +import { Component, Input } from '@angular/core'; +import { AdminNotifyMetricsRow } from './admin-notify-metrics.model'; +import { Router } from '@angular/router'; +import { ViewMode } from '../../../core/shared/view-mode.model'; + +@Component({ + selector: 'ds-admin-notify-metrics', + templateUrl: './admin-notify-metrics.component.html', +}) +/** + * Component used to display the number of notification for each configured box in the notifyMetrics section + */ + +export class AdminNotifyMetricsComponent { + + @Input() + boxesConfig: AdminNotifyMetricsRow[]; + + private incomingConfiguration = 'NOTIFY.incoming'; + private involvedItemsSuffix = 'involvedItems'; + private inboundPath = '/inbound'; + private outboundPath = '/outbound'; + private adminSearchPath = '/admin/search'; + + constructor(private router: Router) { + } + + + public navigateToSelectedSearchConfig(searchConfig: string) { + const isRelatedItemsConfig = searchConfig.endsWith(this.involvedItemsSuffix); + + if (isRelatedItemsConfig) { + this.router.navigate([this.adminSearchPath], { + queryParams: { + configuration: searchConfig, + view: ViewMode.ListElement + }, + }); + + return; + } + + const isIncomingConfig = searchConfig.startsWith(this.incomingConfiguration); + const selectedPath = isIncomingConfig ? this.inboundPath : this.outboundPath; + + this.router.navigate([`${this.router.url}${selectedPath}`], { + queryParams: { + configuration: searchConfig, + view: ViewMode.Table + }, + }); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts new file mode 100644 index 00000000000..83b931c8668 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts @@ -0,0 +1,19 @@ +/** + * The properties for each Box to be displayed in rows in the AdminNotifyMetricsComponent + */ + +export interface AdminNotifyMetricsBox { + color: string; + textColor?: string; + title: string; + description: string; + config: string; + count?: number; +} +/** + * The properties for each Row containing a list of AdminNotifyMetricsBox to be displayed in the AdminNotifyMetricsComponent + */ +export interface AdminNotifyMetricsRow { + title: string; + boxes: AdminNotifyMetricsBox[] +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html new file mode 100644 index 00000000000..af540b094e9 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html @@ -0,0 +1,51 @@ +
+ + + + + + + + + + + + + + + + + + + + + +
{{ 'notify-message-result.timestamp' | translate}}{{'notify-message-result.repositoryItem' | translate}}{{ 'notify-message-result.ldnService' | translate}}{{ 'notify-message-result.type' | translate }}{{ 'notify-message-result.status' | translate }}{{ 'notify-message-result.action' | translate }}
+
{{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}
+
n/a
+
+ + + {{ message.relatedItem }} + + +
n/a
+
+
{{ message.ldnService }}
+
n/a
+
+
{{ message.activityStreamType }}
+
+
{{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}
+
+
+ + +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts new file mode 100644 index 00000000000..08f60a8f5c4 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts @@ -0,0 +1,182 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminNotifySearchResultComponent } from './admin-notify-search-result.component'; +import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { cold } from 'jasmine-marbles'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; +import { RouteService } from '../../../core/services/route.service'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; +import { ActivatedRoute } from '@angular/router'; +import { RouterStub } from '../../../shared/testing/router.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf, of } from 'rxjs'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; +import { DatePipe } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + + +export const mockAdminNotifyMessages = [ + { + 'type': 'message', + 'id': 'urn:uuid:5fb3af44-d4f8-4226-9475-2d09c2d8d9e0', + 'coarNotifyType': 'coar-notify:ReviewAction', + 'activityStreamType': 'TentativeReject', + 'inReplyTo': 'urn:uuid:f7289ad5-0955-4c86-834c-fb54a736778b', + 'object': null, + 'context': '24d50450-9ff0-485f-82d4-fba1be42f3f9', + 'queueAttempts': 1, + 'queueLastStartTime': '2023-11-24T14:44:00.064+00:00', + 'origin': 12, + 'target': null, + 'queueStatusLabel': 'notify-queue-status.processed', + 'queueTimeout': '2023-11-24T15:44:00.064+00:00', + 'queueStatus': 3, + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/ldn/messages/urn:uuid:5fb3af44-d4f8-4226-9475-2d09c2d8d9e0' + } + }, + 'thumbnail': 'test', + 'item': {}, + 'accessStatus': {}, + 'ldnService': 'NOTIFY inbox - Automatic service', + 'relatedItem': 'test coar 2 demo', + 'message': '{"@context":["https://www.w3.org/ns/activitystreams","https://purl.org/coar/notify"],"id":"urn:uuid:668f26e0-2e8d-4118-b0d2-ee713523bc45","type":["Reject","coar-notify:IngestAction"],"actor":{"id":"https://generic-service.com","type":["Service"],"name":"Generic Service"},"context":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Document"],"ietf:cite-as":"https://doi.org/10.4598/12123488"},"object":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Offer"]},"origin":{"id":"https://generic-service.com/system","type":["Service"],"inbox":"https://notify-inbox.info/inbox7"},"target":{"id":"https://some-organisation.org","type":["Organization"],"inbox":"https://dspace-coar.4science.cloud/server/ldn/inbox"},"inReplyTo":"urn:uuid:d9b4010a-f128-4815-abb2-83707a2ee9cf"}' + }, + { + 'type': 'message', + 'id': 'urn:uuid:544c8777-e826-4810-a625-3e394cc3660d', + 'coarNotifyType': 'coar-notify:IngestAction', + 'activityStreamType': 'Announce', + 'inReplyTo': 'urn:uuid:b2ad72d6-6ea9-464f-b385-29a78417f6b8', + 'object': null, + 'context': 'e657437a-0ee2-437d-916a-bba8c57bf40b', + 'queueAttempts': 1, + 'queueLastStartTime': null, + 'origin': 12, + 'target': null, + 'queueStatusLabel': 'notify-queue-status.unmapped_action', + 'queueTimeout': '2023-11-24T14:15:34.945+00:00', + 'queueStatus': 6, + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/ldn/messages/urn:uuid:544c8777-e826-4810-a625-3e394cc3660d' + } + }, + 'thumbnail': {}, + 'item': {}, + 'accessStatus': {}, + 'ldnService': 'NOTIFY inbox - Automatic service', + 'relatedItem': 'test coar demo', + 'message': '{"@context":["https://www.w3.org/ns/activitystreams","https://purl.org/coar/notify"],"id":"urn:uuid:668f26e0-2e8d-4118-b0d2-ee713523bc45","type":["Reject","coar-notify:IngestAction"],"actor":{"id":"https://generic-service.com","type":["Service"],"name":"Generic Service"},"context":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Document"],"ietf:cite-as":"https://doi.org/10.4598/12123488"},"object":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Offer"]},"origin":{"id":"https://generic-service.com/system","type":["Service"],"inbox":"https://notify-inbox.info/inbox7"},"target":{"id":"https://some-organisation.org","type":["Organization"],"inbox":"https://dspace-coar.4science.cloud/server/ldn/inbox"},"inReplyTo":"urn:uuid:d9b4010a-f128-4815-abb2-83707a2ee9cf"}' + } +] as unknown as AdminNotifyMessage[]; +describe('AdminNotifySearchResultComponent', () => { + let component: AdminNotifySearchResultComponent; + let fixture: ComponentFixture; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + let rdbService: RemoteDataBuildService; + let adminNotifyMessageService: AdminNotifyMessagesService; + let searchConfigService: SearchConfigurationService; + let modalService: NgbModal; + const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; + const testObject = { + uuid: 'test-property', + name: 'test-property', + values: ['value-1', 'value-2'] + } as ConfigurationProperty; + + beforeEach(async () => { + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: '' }) + }); + adminNotifyMessageService = jasmine.createSpyObj('adminNotifyMessageService', { + getDetailedMessages: of(mockAdminNotifyMessages), + reprocessMessage: of(mockAdminNotifyMessages), + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('a', { + a: { + payload: testObject + } + }) + }); + + searchConfigService = jasmine.createSpyObj('searchConfigService', { + getCurrentConfiguration: of('NOTIFY.outgoing') + }); + objectCache = {} as ObjectCacheService; + + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifySearchResultComponent, AdminNotifyDetailModalComponent ], + providers: [ + { provide: AdminNotifyMessagesService, useValue: adminNotifyMessageService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new RouterStub() }, + { provide: HALEndpointService, useValue: halService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: SEARCH_CONFIG_SERVICE, useValue: searchConfigService }, + DatePipe + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifySearchResultComponent); + component = fixture.componentInstance; + component.searchConfigService = searchConfigService; + modalService = (component as any).modalService; + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.isInbound).toBeFalsy(); + }); + + it('should open modal', () => { + component.openDetailModal(mockAdminNotifyMessages[0]); + expect(modalService.open).toHaveBeenCalledWith(AdminNotifyDetailModalComponent); + }); + + it('should map messages', (done) => { + component.messagesSubject$.subscribe((messages) => { + expect(messages).toEqual(mockAdminNotifyMessages); + done(); + }); + }); + + it('should reprocess message', (done) => { + component.reprocessMessage(mockAdminNotifyMessages[0]); + component.messagesSubject$.subscribe((messages) => { + expect(messages).toEqual(mockAdminNotifyMessages); + done(); + }); + }); + + it('should unsubscribe on destroy', () => { + (component as any).subs = [of(null).subscribe()]; + + spyOn((component as any).subs[0], 'unsubscribe'); + component.ngOnDestroy(); + expect((component as any).subs[0].unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts new file mode 100644 index 00000000000..11e80209862 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts @@ -0,0 +1,157 @@ +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { AdminNotifySearchResult } from '../models/admin-notify-message-search-result.model'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { Context } from '../../../core/shared/context.model'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { + tabulatableObjectsComponent +} from '../../../shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator'; +import { + TabulatableResultListElementsComponent +} from '../../../shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; +import { DatePipe } from '@angular/common'; + +@tabulatableObjectsComponent(PaginatedList, ViewMode.Table, Context.CoarNotify) +@Component({ + selector: 'ds-admin-notify-search-result', + templateUrl: './admin-notify-search-result.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +/** + * Component for visualization in table format of the search results related to the AdminNotifyDashboardComponent + */ + + +export class AdminNotifySearchResultComponent extends TabulatableResultListElementsComponent, AdminNotifySearchResult> implements OnInit, OnDestroy{ + public messagesSubject$: BehaviorSubject = new BehaviorSubject([]); + public reprocessStatus = 'QUEUE_STATUS_QUEUED_FOR_RETRY'; + //we check on one type of config to render specific table headers + public isInbound: boolean; + + /** + * Statuses for which we display the reprocess button + */ + public validStatusesForReprocess = [ + 'QUEUE_STATUS_UNTRUSTED', + 'QUEUE_STATUS_UNTRUSTED_IP', + 'QUEUE_STATUS_FAILED', + 'QUEUE_STATUS_UNMAPPED_ACTION' + ]; + + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Keys to be formatted as date + * @private + */ + + private dateTypeKeys: string[] = ['queueLastStartTime', 'queueTimeout']; + + /** + * Keys to be not shown in detail + * @private + */ + private messageKeys: string[] = [ + 'type', + 'id', + 'coarNotifyType', + 'activityStreamType', + 'inReplyTo', + 'queueAttempts', + 'queueLastStartTime', + 'queueStatusLabel', + 'queueTimeout' + ]; + + /** + * The format for the date values + * @private + */ + private dateFormat = 'YYYY/MM/d hh:mm:ss'; + + constructor(private modalService: NgbModal, + private adminNotifyMessagesService: AdminNotifyMessagesService, + private datePipe: DatePipe, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { + super(); + } + + /** + * Map messages on init for readable representation + */ + ngOnInit() { + this.mapDetailsToMessages(); + this.subs.push(this.searchConfigService.getCurrentConfiguration('') + .subscribe(configuration => { + this.isInbound = configuration.startsWith('NOTIFY.incoming'); + }) + ); + } + + ngOnDestroy() { + this.subs.forEach(sub => sub.unsubscribe()); + } + + /** + * Open modal for details visualization + * @param notifyMessage the message to be displayed + */ + openDetailModal(notifyMessage: AdminNotifyMessage) { + const modalRef = this.modalService.open(AdminNotifyDetailModalComponent); + const messageToOpen = {...notifyMessage}; + + this.messageKeys.forEach(key => { + if (this.dateTypeKeys.includes(key)) { + messageToOpen[key] = this.datePipe.transform(messageToOpen[key], this.dateFormat); + } + }); + // format COAR message for technical visualization + messageToOpen.message = JSON.stringify(JSON.parse(notifyMessage.message), null, 2); + + modalRef.componentInstance.notifyMessage = messageToOpen; + modalRef.componentInstance.notifyMessageKeys = this.messageKeys; + } + + /** + * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results + * @param message the message to be reprocessed + */ + reprocessMessage(message: AdminNotifyMessage) { + this.subs.push( + this.adminNotifyMessagesService.reprocessMessage(message, this.messagesSubject$) + .subscribe(response => { + this.messagesSubject$.next(response); + } + ) + ); + } + + + /** + * Map readable results to messages + * @private + */ + private mapDetailsToMessages() { + this.subs.push(this.adminNotifyMessagesService.getDetailedMessages(this.objects?.page.map(pageResult => pageResult.indexableObject)) + .subscribe(response => { + this.messagesSubject$.next(response); + })); + } +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts new file mode 100644 index 00000000000..236a564f203 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts @@ -0,0 +1,8 @@ +import { AdminNotifyMessage } from './admin-notify-message.model'; +import { searchResultFor } from '../../../shared/search/search-result-element-decorator'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; + + +@searchResultFor(AdminNotifyMessage) +export class AdminNotifySearchResult extends SearchResult { +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts new file mode 100644 index 00000000000..cca26f0fb62 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts @@ -0,0 +1,165 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { ADMIN_NOTIFY_MESSAGE } from './admin-notify-message.resource-type'; +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { Observable } from 'rxjs'; +/** + * A message that includes admin notify info + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class AdminNotifyMessage extends DSpaceObject { + static type = ADMIN_NOTIFY_MESSAGE; + + /** + * The type of the resource + */ + @excludeFromEquals + type = ADMIN_NOTIFY_MESSAGE; + + /** + * The id of the message + */ + @autoserialize + id: string; + + /** + * The id of the notification + */ + @autoserialize + notificationId: string; + + /** + * The type of the notification + */ + @autoserialize + notificationType: string; + + /** + * The type of the notification + */ + @autoserialize + coarNotifyType: string; + + /** + * The type of the activity + */ + @autoserialize + activityStreamType: string; + + /** + * The object the message reply to + */ + @autoserialize + inReplyTo: string; + + /** + * The object the message relates to + */ + @autoserialize + object: string; + + /** + * The name of the related item + */ + @autoserialize + relatedItem: string; + + /** + * The name of the related ldn service + */ + @autoserialize + ldnService: string; + + /** + * The context of the message + */ + @autoserialize + context: string; + + /** + * The related COAR message + */ + @autoserialize + message: string; + + /** + * The attempts of the queue + */ + @autoserialize + queueAttempts: number; + + /** + * Timestamp of the last queue attempt + */ + @autoserialize + queueLastStartTime: string; + + /** + * The type of the activity stream + */ + @autoserialize + origin: number | string; + + /** + * The type of the activity stream + */ + @autoserialize + target: number | string; + + /** + * The label for the status of the queue + */ + @autoserialize + queueStatusLabel: string; + + /** + * The timeout of the queue + */ + @autoserialize + queueTimeout: string; + + /** + * The status of the queue + */ + @autoserialize + queueStatus: number; + + /** + * Thumbnail link used when browsing items with showThumbs config enabled. + */ + @autoserialize + thumbnail: string; + + /** + * The observable pointing to the item itself + */ + @autoserialize + item: Observable; + + /** + * The observable pointing to the access status of the item + */ + @autoserialize + accessStatus: Observable; + + + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } + + getRenderTypes(): (string | GenericConstructor)[] { + return [this.constructor as GenericConstructor]; + } +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts new file mode 100644 index 00000000000..994146adb3d --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../core/shared/resource-type'; + +/** + * The resource type for AdminNotifyMessage + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ADMIN_NOTIFY_MESSAGE = new ResourceType('message'); diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts new file mode 100644 index 00000000000..975950a33de --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts @@ -0,0 +1,114 @@ +import { cold } from 'jasmine-marbles'; +import { AdminNotifyMessagesService } from './admin-notify-messages.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 { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; +import { RestResponse } from '../../../core/cache/response.models'; +import { BehaviorSubject, of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestEntryState } from '../../../core/data/request-entry-state.model'; +import { + mockAdminNotifyMessages} from '../admin-notify-search-result/admin-notify-search-result.component.spec'; +import { take } from 'rxjs/operators'; +import { deepClone } from 'fast-json-patch'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; + +describe('AdminNotifyMessagesService test', () => { + let service: AdminNotifyMessagesService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let ldnServicesService: LdnServicesService; + let itemDataService: ItemDataService; + let responseCacheEntry: RequestEntry; + let mockMessages: AdminNotifyMessage[]; + + const endpointURL = `https://rest.api/rest/api/ldn/messages`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + const testLdnServiceName = 'testLdnService'; + const testRelatedItemName = 'testRelatedItem'; + + function initTestService() { + return new AdminNotifyMessagesService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ldnServicesService, + itemDataService + ); + } + + beforeEach(() => { + mockMessages = deepClone(mockAdminNotifyMessages); + objectCache = {} as ObjectCacheService; + notificationsService = {} as NotificationsService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: endpointURL } 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 }), + buildFromRequestUUID: createSuccessfulRemoteDataObject$(mockMessages) + }); + + ldnServicesService = jasmine.createSpyObj('ldnServicesService', { + findById: createSuccessfulRemoteDataObject$({name: testLdnServiceName}), + }); + + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$({name: testRelatedItemName}), + }); + + service = initTestService(); + }); + + describe('Admin Notify service', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should get details for messages', (done) => { + service.getDetailedMessages(mockMessages).pipe(take(1)).subscribe((detailedMessages) => { + expect(detailedMessages[0].ldnService).toEqual(testLdnServiceName); + expect(detailedMessages[0].relatedItem).toEqual(testRelatedItemName); + done(); + }); + }); + + it('should reprocess message', (done) => { + const behaviorSubject = new BehaviorSubject(mockMessages); + service.reprocessMessage(mockMessages[0], behaviorSubject).pipe(take(1)).subscribe((reprocessedMessages) => { + expect(reprocessedMessages.length).toEqual(2); + expect(reprocessedMessages).toEqual(mockMessages); + done(); + }); + }); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts new file mode 100644 index 00000000000..ee78957abeb --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts @@ -0,0 +1,100 @@ +import {Injectable} from '@angular/core'; +import {dataService} from '../../../core/data/base/data-service.decorator'; +import {IdentifiableDataService} from '../../../core/data/base/identifiable-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 { BehaviorSubject, from, Observable, of, scan } from 'rxjs'; +import { ADMIN_NOTIFY_MESSAGE } from '../models/admin-notify-message.resource-type'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { PostRequest } from '../../../core/data/request.models'; +import { RestRequest } from '../../../core/data/rest-request.model'; + +/** + * Injectable service responsible for fetching/sending data from/to the REST API on the messages endpoint. + * + * @export + * @class AdminNotifyMessagesService + * @extends {IdentifiableDataService} + */ +@Injectable() +@dataService(ADMIN_NOTIFY_MESSAGE) +export class AdminNotifyMessagesService extends IdentifiableDataService { + + protected reprocessEndpoint = 'enqueueretry'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + private ldnServicesService: LdnServicesService, + private itemDataService: ItemDataService, + ) { + super('messages', requestService, rdbService, objectCache, halService); + } + + + /** + * Add detailed information to each message + * @param messages the messages to which add detailded info + */ + public getDetailedMessages(messages: AdminNotifyMessage[]): Observable { + return from(messages).pipe( + mergeMap(message => + message.target || message.origin ? this.ldnServicesService.findById((message.target || message.origin).toString()).pipe( + getAllSucceededRemoteDataPayload(), + map(detail => ({...message, ldnService: detail.name})) + ) : of(message), + ), + mergeMap(message => + message.object || message.context ? this.itemDataService.findById(message.object || message.context).pipe( + getAllSucceededRemoteDataPayload(), + map(detail => ({...message, relatedItem: detail.name})) + ) : of(message), + ), + scan((acc: any, value: any) => [...acc, value], []), + ); + } + + /** + * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results + * @param message the message to reprocess + * @param messageSubject the current visualised messages source + */ + public reprocessMessage(message: AdminNotifyMessage, messageSubject: BehaviorSubject): Observable { + const requestId = this.requestService.generateRequestId(); + + return this.halService.getEndpoint(this.reprocessEndpoint).pipe( + map(endpoint => endpoint.replace('{id}', message.id)), + map((endpointURL: string) => new PostRequest(requestId, endpointURL)), + tap(request => this.requestService.send(request)), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), + getFirstCompletedRemoteData(), + getAllSucceededRemoteDataPayload(), + mergeMap(reprocessedMessage => this.getDetailedMessages([reprocessedMessage])), + ).pipe( + mergeMap((newMessages) => messageSubject.pipe( + map(messages => { + const detailedReprocessedMessage = newMessages[0]; + const messageToUpdate = messages.find(currentMessage => currentMessage.id === message.id); + const indexOfMessageToUpdate = messages.indexOf(messageToUpdate); + detailedReprocessedMessage.target = message.target; + detailedReprocessedMessage.object = message.object; + detailedReprocessedMessage.origin = message.origin; + detailedReprocessedMessage.context = message.context; + messages[indexOfMessageToUpdate] = detailedReprocessedMessage; + + return messages; + }) + )), + ); + } +} 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 @@ +
+ +
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/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/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..94372ebab7d --- /dev/null +++ b/src/app/admin/admin-reports/filters-section/filters-section.component.ts @@ -0,0 +1,148 @@ +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' +}) +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 3168ea93c92..3c45081d704 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -1,8 +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 const NOTIFY_DASHBOARD_MODULE_PATH = 'notify-dashboard'; + 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 8e4f13b1641..14f241342fa 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -6,12 +6,21 @@ 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 } from './admin-routing-paths'; +import { + LDN_PATH, + NOTIFICATIONS_MODULE_PATH, NOTIFY_DASHBOARD_MODULE_PATH, + REGISTRIES_MODULE_PATH, REPORTS_MODULE_PATH, +} from './admin-routing-paths'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: NOTIFICATIONS_MODULE_PATH, + loadChildren: () => import('./admin-notifications/admin-notifications.module') + .then((m) => m.AdminNotificationsModule), + }, { path: REGISTRIES_MODULE_PATH, loadChildren: () => import('./admin-registries/admin-registries.module') @@ -51,7 +60,28 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import path: 'system-wide-alert', resolve: { breadcrumb: I18nBreadcrumbResolver }, loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule), - data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'} + data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}, + }, + { + 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), + }, + { + path: NOTIFY_DASHBOARD_MODULE_PATH, + loadChildren: () => import('./admin-notify-dashboard/admin-notify-dashboard.module') + .then((m) => m.AdminNotifyDashboardModule), }, ]) ], diff --git a/src/app/admin/admin.module.ts b/src/app/admin/admin.module.ts index 3dc0036854e..e2a2f3194ba 100644 --- a/src/app/admin/admin.module.ts +++ b/src/app/admin/admin.module.ts @@ -10,6 +10,7 @@ import { AdminSearchModule } from './admin-search-page/admin-search.module'; import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; +import { AdminReportsModule } from './admin-reports/admin-reports.module'; import { UiSwitchModule } from 'ngx-ui-switch'; import { UploadModule } from '../shared/upload/upload.module'; @@ -24,6 +25,7 @@ const ENTRY_COMPONENTS = [ imports: [ AdminRoutingModule, AdminRegistriesModule, + AdminReportsModule, AccessControlModule, AdminSearchModule.withEntryComponents(), AdminWorkflowModuleModule.withEntryComponents(), diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index fe2837c6e3f..7894c980b43 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -31,6 +31,7 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st } }; } +export const COAR_NOTIFY_SUPPORT = 'coar-notify-support'; export const HOME_PAGE_PATH = 'admin'; @@ -132,3 +133,5 @@ export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; export function getSubscriptionsModuleRoute() { return `/${SUBSCRIPTIONS_MODULE_PATH}`; } + +export const CORRECTION_TYPE_PATH = 'corrections'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index deb68f1ea92..b72f3751618 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -38,8 +38,10 @@ import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; +import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths'; import { MenuResolver } from './menu.resolver'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; +import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; @NgModule({ imports: [ @@ -156,6 +158,18 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone .then((m) => m.AdminModule), canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] }, + { + path: NOTIFICATIONS_MODULE_PATH, + loadChildren: () => import('./admin/admin-notifications/admin-notifications.module') + .then((m) => m.AdminNotificationsModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, + { + path: NOTIFICATIONS_MODULE_PATH, + loadChildren: () => import('./quality-assurance-notifications-pages/notifications-pages.module') + .then((m) => m.NotificationsPageModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, { path: 'login', loadChildren: () => import('./login-page/login-page.module') @@ -202,6 +216,11 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone .then((m) => m.ProcessPageModule), canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, + { path: SUGGESTION_MODULE_PATH, + loadChildren: () => import('./suggestions-page/suggestions-page.module') + .then((m) => m.SuggestionsPageModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, { path: INFO_MODULE_PATH, loadChildren: () => import('./info/info.module').then((m) => m.InfoModule) diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html new file mode 100644 index 00000000000..52ef06206f0 --- /dev/null +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html @@ -0,0 +1,21 @@ +
+ +
diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index de67607bb4b..52960e37150 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -4,7 +4,7 @@
-
diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..81b3b8ad2ae --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts @@ -0,0 +1,52 @@ +import { NavigationBreadcrumbResolver } from './navigation-breadcrumb.resolver'; + +describe('NavigationBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: NavigationBreadcrumbResolver; + let NavigationBreadcrumbService: any; + let i18nKey: string; + let relatedI18nKey: string; + let route: any; + let expectedPath; + let state; + beforeEach(() => { + i18nKey = 'example.key'; + relatedI18nKey = 'related.key'; + route = { + data: { + breadcrumbKey: i18nKey, + relatedRoutes: [ + { + path: '', + data: {breadcrumbKey: relatedI18nKey}, + } + ] + }, + routeConfig: { + path: 'example' + }, + parent: { + routeConfig: { + path: '' + }, + url: [{ + path: 'base' + }] + } as any + }; + + state = { + url: '/base/example' + }; + expectedPath = '/base/example:/base'; + NavigationBreadcrumbService = {}; + resolver = new NavigationBreadcrumbResolver(NavigationBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route, state); + const expectedConfig = { provider: NavigationBreadcrumbService, key: `${i18nKey}:${relatedI18nKey}`, url: expectedPath }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts new file mode 100644 index 00000000000..18ebfc395b7 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts @@ -0,0 +1,52 @@ +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +/** + * The class that resolves a BreadcrumbConfig object with an i18n key string for a route and related parents + */ +@Injectable({ + providedIn: 'root' +}) +export class NavigationBreadcrumbResolver implements Resolve> { + + private parentRoutes: ActivatedRouteSnapshot[] = []; + constructor(protected breadcrumbService: NavigationBreadcrumbsService) { + } + + /** + * Method to collect all parent routes snapshot from current route snapshot + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + */ + private getParentRoutes(route: ActivatedRouteSnapshot): void { + if (route.parent) { + this.parentRoutes.push(route.parent); + this.getParentRoutes(route.parent); + } + } + /** + * Method for resolving an I18n breadcrumb configuration object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + this.getParentRoutes(route); + const relatedRoutes = route.data.relatedRoutes; + const parentPaths = this.parentRoutes.map(parent => parent.routeConfig?.path); + const relatedParentRoutes = relatedRoutes.filter(relatedRoute => parentPaths.includes(relatedRoute.path)); + const baseUrlSegmentPath = route.parent.url[route.parent.url.length - 1].path; + const baseUrl = state.url.substring(0, state.url.lastIndexOf(baseUrlSegmentPath) + baseUrlSegmentPath.length); + + + const combinedParentBreadcrumbKeys = relatedParentRoutes.reduce((previous, current) => { + return `${previous}:${current.data.breadcrumbKey}`; + }, route.data.breadcrumbKey); + const combinedUrls = relatedParentRoutes.reduce((previous, current) => { + return `${previous}:${baseUrl}${current.path}`; + }, state.url); + + return {provider: this.breadcrumbService, key: combinedParentBreadcrumbKeys, url: combinedUrls}; + } +} diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts new file mode 100644 index 00000000000..beebeed94e1 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts @@ -0,0 +1,30 @@ +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { Observable, of as observableOf } from 'rxjs'; +import { Injectable } from '@angular/core'; + +/** + * The postfix for i18n breadcrumbs + */ +export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; + +/** + * Service to calculate i18n breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root' +}) +export class NavigationBreadcrumbsService implements BreadcrumbsProviderService { + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: string, url: string): Observable { + const keys = key.split(':'); + const urls = url.split(':'); + const breadcrumbs = keys.map((currentKey, index) => new Breadcrumb(currentKey + BREADCRUMB_MESSAGE_POSTFIX, urls[index] )); + return observableOf(breadcrumbs.reverse()); + } +} diff --git a/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts new file mode 100644 index 00000000000..98e20e285d9 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { BREADCRUMB_MESSAGE_POSTFIX } from './i18n-breadcrumbs.service'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +describe('NavigationBreadcrumbsService', () => { + let service: NavigationBreadcrumbsService; + let exampleString; + let exampleURL; + let childrenString; + let childrenUrl; + let parentString; + let parentUrl; + + function init() { + exampleString = 'example.string:parent.string'; + exampleURL = 'example.com:parent.com'; + childrenString = 'example.string'; + childrenUrl = 'example.com'; + parentString = 'parent.string'; + parentUrl = 'parent.com'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new NavigationBreadcrumbsService(); + }); + + describe('getBreadcrumbs', () => { + it('should return an array of breadcrumbs based on strings by adding the postfix', () => { + const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [ + new Breadcrumb(childrenString + BREADCRUMB_MESSAGE_POSTFIX, childrenUrl), + new Breadcrumb(parentString + BREADCRUMB_MESSAGE_POSTFIX, parentUrl), + ].reverse() }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..b6f41424693 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import { PublicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; + +describe('PublicationClaimBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: PublicationClaimBreadcrumbResolver; + let publicationClaimBreadcrumbService: any; + const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedId = 'openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + let route; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + targetId: expectedId, + } + }; + publicationClaimBreadcrumbService = {}; + resolver = new PublicationClaimBreadcrumbResolver(publicationClaimBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route as any, {url: fullPath } as any); + const expectedConfig = { provider: publicationClaimBreadcrumbService, key: expectedKey }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts new file mode 100644 index 00000000000..713500d6a73 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +@Injectable({ + providedIn: 'root' +}) +export class PublicationClaimBreadcrumbResolver implements Resolve> { + constructor(protected breadcrumbService: PublicationClaimBreadcrumbService) { + } + + /** + * Method that resolve Publication Claim item into a breadcrumb + * The parameter are retrieved by the url since part of the Publication Claim route config + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + const targetId = route.paramMap.get('targetId').split(':')[1]; + return { provider: this.breadcrumbService, key: targetId }; + } +} diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts new file mode 100644 index 00000000000..11062210bb3 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts @@ -0,0 +1,51 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { of } from 'rxjs'; + +describe('PublicationClaimBreadcrumbService', () => { + let service: PublicationClaimBreadcrumbService; + let dsoNameService: any = { + getName: (str) => str + }; + let translateService: any = { + instant: (str) => str, + }; + + let dataService: any = { + findById: (str) => createSuccessfulRemoteDataObject$(str), + }; + + let authorizationService: any = { + isAuthorized: (str) => of(true), + }; + + let exampleKey; + + const ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + const ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + function init() { + exampleKey = 'suggestion.suggestionFor.breadcrumb'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new PublicationClaimBreadcrumbService(dataService,dsoNameService,translateService, authorizationService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleKey); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY, ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(exampleKey, undefined)] + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts new file mode 100644 index 00000000000..1a87fd7de60 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts @@ -0,0 +1,46 @@ +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { combineLatest, Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { ItemDataService } from '../data/item-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { map } from 'rxjs/operators'; +import { DSONameService } from './dso-name.service'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../data/feature-authorization/feature-id'; + + + +/** + * Service to calculate Publication claims breadcrumbs + */ +@Injectable({ + providedIn: 'root' +}) +export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService { + private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + private ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + constructor(private dataService: ItemDataService, + private dsoNameService: DSONameService, + private tranlsateService: TranslateService, + protected authorizationService: AuthorizationDataService) { + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + */ + getBreadcrumbs(key: string): Observable { + return combineLatest([this.dataService.findById(key).pipe(getFirstCompletedRemoteData()),this.authorizationService.isAuthorized(FeatureID.AdministratorOf)]).pipe( + map(([item, isAdmin]) => { + const itemName = this.dsoNameService.getName(item.payload); + return isAdmin ? [new Breadcrumb(this.tranlsateService.instant(this.ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY), this.ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)] : + [new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)]; + }) + ); + } +} diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..3544af62e7a --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import {QualityAssuranceBreadcrumbResolver} from './quality-assurance-breadcrumb.resolver'; + +describe('QualityAssuranceBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: QualityAssuranceBreadcrumbResolver; + let qualityAssuranceBreadcrumbService: any; + let route: any; + const fullPath = '/test/quality-assurance/'; + const expectedKey = 'testSourceId:testTopicId'; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + sourceId: 'testSourceId', + topicId: 'testTopicId' + } + }; + qualityAssuranceBreadcrumbService = {}; + resolver = new QualityAssuranceBreadcrumbResolver(qualityAssuranceBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route as any, {url: fullPath + 'testSourceId'} as any); + const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts new file mode 100644 index 00000000000..6eb351ab1ab --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; + +@Injectable({ + providedIn: 'root' +}) +export class QualityAssuranceBreadcrumbResolver implements Resolve> { + constructor(protected breadcrumbService: QualityAssuranceBreadcrumbService) {} + + /** + * Method that resolve QA item into a breadcrumb + * The parameter are retrieved by the url since part of the QA route config + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + const sourceId = route.paramMap.get('sourceId'); + const topicId = route.paramMap.get('topicId'); + let key = sourceId; + + if (topicId) { + key += `:${topicId}`; + } + const fullPath = state.url; + const url = fullPath.substr(0, fullPath.indexOf(sourceId)); + + return { provider: this.breadcrumbService, key, url }; + } +} diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts new file mode 100644 index 00000000000..cefa1d2f6fb --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service'; + +describe('QualityAssuranceBreadcrumbService', () => { + let service: QualityAssuranceBreadcrumbService; + let translateService: any = { + instant: (str) => str, + }; + + let exampleString; + let exampleURL; + let exampleQaKey; + + function init() { + exampleString = 'sourceId'; + exampleURL = '/test/quality-assurance/'; + exampleQaKey = 'admin.quality-assurance.breadcrumbs'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new QualityAssuranceBreadcrumbService(translateService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL), + new Breadcrumb(exampleString, exampleURL + exampleString)] + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts new file mode 100644 index 00000000000..a0299705a40 --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts @@ -0,0 +1,45 @@ +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { Observable, of as observableOf } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + + + +/** + * Service to calculate QA breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root' +}) +export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService { + + private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs'; + constructor( + private translationService: TranslateService, + ) { + + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: string, url: string): Observable { + const args = key.split(':'); + const sourceId = args[0]; + const topicId = args.length > 2 ? args[args.length - 1] : args[1]; + + if (topicId) { + return observableOf( [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + new Breadcrumb(sourceId, `${url}${sourceId}`), + new Breadcrumb(topicId, undefined)]); + } else { + return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + new Breadcrumb(sourceId, `${url}${sourceId}`)]); + } + + } +} diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 9e5ebaed854..779ffc2d1ed 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -5,11 +5,16 @@ import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import { getResourceTypeValueFor } from '../object-cache.reducer'; import { InjectionToken } from '@angular/core'; +import { CacheableObject } from '../cacheable-object.model'; import { TypedObject } from '../typed-object.model'; +export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { + providedIn: 'root', + factory: () => getDataServiceFor +}); export const LINK_DEFINITION_FACTORY = new InjectionToken<(source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition>('getLinkDefinition', { providedIn: 'root', - factory: () => getLinkDefinition, + factory: () => getLinkDefinition }); export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', { providedIn: 'root', @@ -20,6 +25,7 @@ const resolvedLinkKey = Symbol('resolvedLink'); const resolvedLinkMap = new Map(); const typeMap = new Map(); +const dataServiceMap = new Map(); const linkMap = new Map(); /** @@ -38,6 +44,39 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(getResourceTypeValueFor(type)); } +/** + * A class decorator to indicate that this class is a dataservice + * for a given resource type. + * + * "dataservice" in this context means that it has findByHref and + * findAllByHref methods. + * + * @param resourceType the resource type the class is a dataservice for + */ +export function dataService(resourceType: ResourceType): any { + return (target: any) => { + if (hasNoValue(resourceType)) { + throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); + } + const existingDataservice = dataServiceMap.get(resourceType.value); + + if (hasValue(existingDataservice)) { + throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); + } + + dataServiceMap.set(resourceType.value, target); + }; +} + +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ +export function getDataServiceFor(resourceType: ResourceType) { + return dataServiceMap.get(resourceType.value); +} + /** * A class to represent the data that can be set by the @link decorator */ @@ -65,7 +104,7 @@ export const link = ( resourceType: ResourceType, isList = false, linkName?: keyof T['_links'], - ) => { +) => { return (target: T, propertyName: string) => { let targetMap = linkMap.get(target.constructor); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 075bf3ca0ca..5b5e362406c 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -161,6 +161,7 @@ export class RemoteDataBuildService { } else { // in case the elements of the paginated list were already filled in, because they're UnCacheableObjects paginatedList.page = paginatedList.page + .filter((obj: any) => obj != null) .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.html b/src/app/core/coar-notify/notify-info/notify-info.component.html new file mode 100644 index 00000000000..3370f83d03c --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.component.html @@ -0,0 +1,18 @@ +
+ + {{ 'coar-notify-support.title' | translate }} + + +

{{ 'coar-notify-support.title' | translate }}

+

+ +

{{ 'coar-notify-support.ldn-inbox.title' | translate }}

+

+ +

{{ 'coar-notify-support.message-moderation.title' | translate }}

+

+ {{ 'coar-notify-support.message-moderation.content' | translate }} + {{ 'coar-notify-support.message-moderation.feedback-form' | translate }} +

+ +
diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts new file mode 100644 index 00000000000..881e1b67fb2 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotifyInfoComponent } from './notify-info.component'; +import { NotifyInfoService } from './notify-info.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +describe('NotifyInfoComponent', () => { + let component: NotifyInfoComponent; + let fixture: ComponentFixture; + let notifyInfoServiceSpy: any; + + beforeEach(async () => { + notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['getCoarLdnLocalInboxUrls']); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ NotifyInfoComponent ], + providers: [ + { provide: NotifyInfoService, useValue: notifyInfoServiceSpy } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotifyInfoComponent); + component = fixture.componentInstance; + component.coarRestApiUrl = of([]); + spyOn(component, 'generateCoarRestApiLinksHTML').and.returnValue(of('')); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.ts b/src/app/core/coar-notify/notify-info/notify-info.component.ts new file mode 100644 index 00000000000..f1ce210c0ed --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.component.ts @@ -0,0 +1,38 @@ +import { Component, OnInit } from '@angular/core'; +import { NotifyInfoService } from './notify-info.service'; +import { Observable, map, of } from 'rxjs'; + +@Component({ + selector: 'ds-notify-info', + templateUrl: './notify-info.component.html' +}) +/** + * Component for displaying COAR notification information. + */ +export class NotifyInfoComponent implements OnInit { + /** + * Observable containing the COAR REST INBOX API URLs. + */ + coarRestApiUrl: Observable = of([]); + + constructor(private notifyInfoService: NotifyInfoService) {} + + ngOnInit() { + this.coarRestApiUrl = this.notifyInfoService.getCoarLdnLocalInboxUrls(); + } + + /** + * Generates HTML code for COAR REST API links. + * @returns An Observable that emits the generated HTML code. + */ + generateCoarRestApiLinksHTML() { + return this.coarRestApiUrl.pipe( + // transform the data into HTML + map((urls) => { + return urls.map(url => ` + ${url} + `).join(','); + }) + ); + } +} diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts new file mode 100644 index 00000000000..81ac0db8d81 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; + +import { NotifyInfoGuard } from './notify-info.guard'; +import { Router } from '@angular/router'; +import { NotifyInfoService } from './notify-info.service'; +import { of } from 'rxjs'; + +describe('NotifyInfoGuard', () => { + let guard: NotifyInfoGuard; + let notifyInfoServiceSpy: any; + let router: any; + + beforeEach(() => { + notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['isCoarConfigEnabled']); + router = jasmine.createSpyObj('Router', ['parseUrl']); + TestBed.configureTestingModule({ + providers: [ + NotifyInfoGuard, + { provide: NotifyInfoService, useValue: notifyInfoServiceSpy}, + { provide: Router, useValue: router} + ] + }); + guard = TestBed.inject(NotifyInfoGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); + + it('should return true if COAR config is enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true)); + + guard.canActivate(null, null).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it('should call parseUrl method of Router if COAR config is not enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(false)); + router.parseUrl.and.returnValue(of('/404')); + + guard.canActivate(null, null).subscribe(() => { + expect(router.parseUrl).toHaveBeenCalledWith('/404'); + done(); + }); + }); + +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.ts new file mode 100644 index 00000000000..7af08216184 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { Observable } from 'rxjs'; +import { NotifyInfoService } from './notify-info.service'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class NotifyInfoGuard implements CanActivate { + constructor( + private notifyInfoService: NotifyInfoService, + private router: Router + ) {} + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + return this.notifyInfoService.isCoarConfigEnabled().pipe( + map(coarLdnEnabled => { + if (coarLdnEnabled) { + return true; + } else { + return this.router.parseUrl('/404'); + } + }) + ); + } +} diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts new file mode 100644 index 00000000000..a3cc360a969 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; + +import { NotifyInfoService } from './notify-info.service'; +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { of } from 'rxjs'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; + +describe('NotifyInfoService', () => { + let service: NotifyInfoService; + let configurationDataService: any; + let authorizationDataService: any; + beforeEach(() => { + authorizationDataService = { + isAuthorized: jasmine.createSpy('isAuthorized').and.returnValue(of(true)), + }; + configurationDataService = { + findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(of({})), + }; + TestBed.configureTestingModule({ + providers: [ + NotifyInfoService, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: AuthorizationDataService, useValue: authorizationDataService } + ] + }); + service = TestBed.inject(NotifyInfoService); + authorizationDataService = TestBed.inject(AuthorizationDataService); + configurationDataService = TestBed.inject(ConfigurationDataService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should retrieve and map coar configuration', () => { + const mockResponse = { payload: { values: ['true'] } }; + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(of(mockResponse)); + + service.isCoarConfigEnabled().subscribe((result) => { + expect(result).toBe(true); + }); + }); + + it('should retrieve and map LDN local inbox URLs', () => { + const mockResponse = { values: ['inbox1', 'inbox2'] }; + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(of(mockResponse)); + + service.getCoarLdnLocalInboxUrls().subscribe((result) => { + expect(result).toEqual(['inbox1', 'inbox2']); + }); + }); + + it('should return the inbox relation link', () => { + expect(service.getInboxRelationLink()).toBe('http://www.w3.org/ns/ldp#inbox'); + }); +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.ts b/src/app/core/coar-notify/notify-info/notify-info.service.ts new file mode 100644 index 00000000000..a15c64237ce --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators'; +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { map, Observable } from 'rxjs'; +import { ConfigurationProperty } from '../../shared/configuration-property.model'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../data/feature-authorization/feature-id'; + +/** + * Service to check COAR availability and LDN services information for the COAR Notify functionalities + */ +@Injectable({ + providedIn: 'root' +}) +export class NotifyInfoService { + + /** + * The relation link for the inbox + */ + private _inboxRelationLink = 'http://www.w3.org/ns/ldp#inbox'; + + constructor( + private configService: ConfigurationDataService, + protected authorizationService: AuthorizationDataService, + ) {} + + isCoarConfigEnabled(): Observable { + return this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled); + } + + /** + * Get the url of the local inbox from the REST configuration + * @returns the url of the local inbox + */ + getCoarLdnLocalInboxUrls(): Observable { + return this.configService.findByPropertyName('ldn.notify.inbox').pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + map((response: ConfigurationProperty) => { + return response.values; + }) + ); + } + + /** + * Method to get the relation link for the inbox + * @returns the relation link for the inbox + */ + getInboxRelationLink(): string { + return this._inboxRelationLink; + } +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index dbca773375a..28e0d3e6e39 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -157,6 +157,9 @@ import { SequenceService } from './shared/sequence.service'; import { CoreState } from './core-state.model'; import { GroupDataService } from './eperson/group-data.service'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; +import { QualityAssuranceTopicObject } from './notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceSourceObject } from './notifications/qa/models/quality-assurance-source.model'; import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model'; import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model'; import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model'; @@ -182,6 +185,20 @@ import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; +import { CorrectionTypeDataService } from './submission/correctiontype-data.service'; +import { LdnServicesService } from '../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { LdnItemfiltersService } from '../admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service'; +import { + CoarNotifyConfigDataService +} from '../submission/sections/section-coar-notify/coar-notify-config-data.service'; +import { NotifyRequestsStatusDataService } from './data/notify-services-status-data.service'; +import { SuggestionTarget } from './notifications/models/suggestion-target.model'; +import { SuggestionSource } from './notifications/models/suggestion-source.model'; +import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; +import { Itemfilter } from '../admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters'; +import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; +import { AdminNotifyMessage } from '../admin/admin-notify-dashboard/models/admin-notify-message.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -304,7 +321,12 @@ const PROVIDERS = [ OrcidAuthService, OrcidQueueDataService, OrcidHistoryDataService, - SupervisionOrderDataService + SupervisionOrderDataService, + CorrectionTypeDataService, + LdnServicesService, + LdnItemfiltersService, + CoarNotifyConfigDataService, + NotifyRequestsStatusDataService ]; /** @@ -369,9 +391,12 @@ export const models = ShortLivedToken, Registration, UsageReport, + QualityAssuranceTopicObject, + QualityAssuranceEventObject, Root, SearchConfig, SubmissionAccessesModel, + QualityAssuranceSourceObject, AccessStatusObject, ResearcherProfile, OrcidQueue, @@ -380,7 +405,14 @@ export const models = IdentifierData, Subscription, ItemRequest, - BulkAccessConditionOptions + BulkAccessConditionOptions, + SuggestionTarget, + SuggestionSource, + LdnService, + Itemfilter, + SubmissionCoarNotifyConfig, + NotifyRequestsStatus, + AdminNotifyMessage ]; @NgModule({ diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 65f8b3ab2cd..c1a7ac64c26 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -35,7 +36,7 @@ describe('CollectionDataService', () => { let translate: TranslateService; let notificationsService: any; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: any; const mockCollection1: Collection = Object.assign(new Collection(), { @@ -205,14 +206,12 @@ describe('CollectionDataService', () => { buildFromRequestUUID: buildResponse$, buildSingle: buildResponse$ }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') - }); + objectCache = new ObjectCacheServiceStub(); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate); } }); diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 8fef45a9532..3bc3d6c1f08 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -34,4 +34,6 @@ export enum FeatureID { CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', CanSubscribe = 'canSubscribeDso', + CoarNotifyEnabled = 'coarNotifyEnabled', + CanSeeQA = 'canSeeQA', } diff --git a/src/app/core/data/notify-services-status-data.service.spec.ts b/src/app/core/data/notify-services-status-data.service.spec.ts new file mode 100644 index 00000000000..ade6ae41568 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.spec.ts @@ -0,0 +1,81 @@ +import { NotifyRequestsStatusDataService } from './notify-services-status-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestEntry } from './request-entry.model'; +import { RemoteData } from './remote-data'; +import { RequestEntryState } from './request-entry-state.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../cache/response.models'; +import { of } from 'rxjs'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; + +describe('NotifyRequestsStatusDataService test', () => { + let scheduler: TestScheduler; + let service: NotifyRequestsStatusDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const endpointURL = `https://rest.api/rest/api/suggestiontargets`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new NotifyRequestsStatusDataService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + 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 }), + buildFromHref: createSuccessfulRemoteDataObject$({test: 'test'}) + }); + + + service = initTestService(); + }); + + describe('getNotifyRequestsStatus', () => { + it('should get notify status', (done) => { + service.getNotifyRequestsStatus(requestUUID).subscribe((status) => { + expect(halService.getEndpoint).toHaveBeenCalled(); + expect(requestService.generateRequestId).toHaveBeenCalled(); + expect(status).toEqual(createSuccessfulRemoteDataObject({test: 'test'} as unknown as NotifyRequestsStatus)); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/notify-services-status-data.service.ts b/src/app/core/data/notify-services-status-data.service.ts new file mode 100644 index 00000000000..84fe4e9d815 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { NOTIFYREQUEST} from '../../item-page/simple/notify-requests-status/notify-requests-status.resource-type'; +import { Observable, map, take } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; + + +@Injectable() +@dataService(NOTIFYREQUEST) +export class NotifyRequestsStatusDataService extends IdentifiableDataService { + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('notifyrequests', requestService, rdbService, objectCache, halService); + } + + /** + * Retrieves the status of notify requests for a specific item. + * @param itemUuid The UUID of the item. + * @returns An Observable that emits the remote data containing the notify requests status. + */ + getNotifyRequestsStatus(itemUuid: string): Observable> { + const href$ = this.getEndpoint().pipe( + map((url: string) => url + '/' + itemUuid ), + ); + + href$.pipe(take(1)).subscribe((url: string) => { + const request = new GetRequest(this.requestService.generateRequestId(), url); + this.requestService.send(request, true); + }); + + return this.rdbService.buildFromHref(href$); + } +} diff --git a/src/app/core/data/update-data.service.spec.ts b/src/app/core/data/update-data.service.spec.ts new file mode 100644 index 00000000000..426fa87eb6d --- /dev/null +++ b/src/app/core/data/update-data.service.spec.ts @@ -0,0 +1,144 @@ +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { HrefOnlyDataService } from './href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { Version } from '../shared/version.model'; +import { VersionHistory } from '../shared/version-history.model'; +import { RequestEntry } from './request-entry.model'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { UpdateDataServiceImpl } from './update-data.service'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +describe('VersionDataService test', () => { + let scheduler: TestScheduler; + let service: UpdateDataServiceImpl; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const notificationsService = {} as NotificationsService; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1', + draftVersion: true, + }); + + const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(item), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + version: 1, + }); + const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion); + + const endpointURL = `https://rest.api/rest/api/versioning/versions`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const comparatorEntry = {} as any; + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new UpdateDataServiceImpl( + 'testLinkPath', + requestService, + rdbService, + objectCache, + halService, + notificationsService, + comparatorEntry, + 10 * 1000 + ); + } + + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + 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), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('(a|)', { + a: mockVersionRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findById').and.callThrough(); + }); + + afterEach(() => { + service = null; + }); + + describe('composition', () => { + const initService = () => new UpdateDataServiceImpl(null, null, null, null, null, null, null, null); + + testPatchDataImplementation(initService); + testSearchDataImplementation(initService); + testDeleteDataImplementation(initService); + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testPutDataImplementation(initService); + }); + +}); diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts index 9f707a82da9..664e2dcabc9 100644 --- a/src/app/core/data/update-data.service.ts +++ b/src/app/core/data/update-data.service.ts @@ -1,13 +1,315 @@ -import { Observable } from 'rxjs'; +import { Operation } from 'fast-json-patch'; +import { AsyncSubject, from as observableFrom, Observable } from 'rxjs'; +import { + find, + map, + mergeMap, + switchMap, + take, + toArray +} from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ChangeAnalyzer } from './change-analyzer'; +import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; +import { + DeleteByIDRequest, + PostRequest +} from './request.models'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; -import { Operation } from 'fast-json-patch'; +import { NoContent } from '../shared/NoContent.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { FindListOptions } from './find-list-options.model'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PutData, PutDataImpl } from './base/put-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; + /** - * Represents a data service to update a given object + * Interface to list the methods used by the injected service in components */ export interface UpdateDataService { patch(dso: T, operations: Operation[]): Observable>; update(object: T): Observable>; - commitUpdates(method?: RestRequestMethod); + commitUpdates(method?: RestRequestMethod): void; +} + + +/** + * Specific functionalities that not all services would need. + * Goal of the class is to update remote objects, handling custom methods that don't belong to BaseDataService + * The class implements also the following common interfaces + * + * findAllData: FindAllData; + * searchData: SearchData; + * createData: CreateData; + * patchData: PatchData; + * putData: PutData; + * deleteData: DeleteData; + * + * Custom methods are: + * + * deleteOnRelated - delete all related objects to the given one + * postOnRelated - post all the related objects to the given one + * invalidate - invalidate the DSpaceObject making all requests as stale + * invalidateByHref - invalidate the href making all requests as stale + */ + +export class UpdateDataServiceImpl extends IdentifiableDataService implements FindAllData, SearchData, CreateData, PatchData, PutData, DeleteData { + private findAllData: FindAllDataImpl; + private searchData: SearchDataImpl; + private createData: CreateDataImpl; + private patchData: PatchDataImpl; + private putData: PutDataImpl; + private deleteData: DeleteDataImpl; + + + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + 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.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator ,this.responseMsToLive, this.constructIdEndpoint); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive, this.constructIdEndpoint); + } + + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.findAllData.getFindAllHref(options, linkPath, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options 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 object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * 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 + */ + searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + createPatchFromCache(object: T): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + return this.putData.put(object); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable> { + return this.patchData.update(object); + } + + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param {CacheableObject} object + * The object to create + * @param {RequestParam[]} params + * Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + /** + * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item= + * @param itemId The item id + * @param relatedItemId The related item Id + * @param body The optional POST body + * @return the RestResponse as an Observable + */ + public postOnRelated(itemId: string, relatedItemId: string, body?: any) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + take(1) + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a delete on an endpoint related item. Ex.: endpoint//related + * @param itemId The item id + * @return the RestResponse as an Observable + */ + public deleteOnRelated(itemId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href + '/related', itemId); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /* + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param objectId The id of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidate(objectId: string): Observable { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.invalidateByHref(href)) + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } + + /** + * Delete an existing DSpace Object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing DSpace Object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod) { + this.patchData.commitUpdates(method); + } } diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index f5a584fd3d0..0982e3ed538 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -1,5 +1,6 @@ import { Store } from '@ngrx/store'; import { + FlushPatchOperationAction, NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, @@ -99,6 +100,20 @@ export class JsonPatchOperationsBuilder { path.path)); } + /** + * Dispatches a new FlushPatchOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + */ + flushOperation(path: JsonPatchOperationPathObject) { + this.store.dispatch( + new FlushPatchOperationAction( + path.rootElement, + path.subRootElement, + path.path)); + } + protected prepareValue(value: any, plain: boolean, first: boolean) { let operationValue: any = null; if (hasValue(value)) { diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts index 6fea7a58fb9..81cce174ec9 100644 --- a/src/app/core/json-patch/json-patch-operations.actions.ts +++ b/src/app/core/json-patch/json-patch-operations.actions.ts @@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = { COMMIT_JSON_PATCH_OPERATIONS: type('dspace/core/patch/COMMIT_JSON_PATCH_OPERATIONS'), ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), + FLUSH_JSON_PATCH_OPERATION: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATION'), START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'), }; @@ -120,6 +121,32 @@ export class FlushPatchOperationsAction implements Action { } } + +/** + * An ngrx action to flush a single operation of the JSON Patch operations + */ +export class FlushPatchOperationAction implements Action { + type = JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string + }; + + /** + * Create a new FlushPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + */ + constructor(resourceType: string, resourceId: string, path: string) { + this.payload = { resourceType, resourceId, path }; + } +} /** * An ngrx action to Add new HTTP/PATCH ADD operations to state */ @@ -284,4 +311,5 @@ export type PatchOperationsActions | NewPatchReplaceOperationAction | RollbacktPatchOperationsAction | StartTransactionPatchOperationsAction - | DeletePendingJsonPatchOperationsAction; + | DeletePendingJsonPatchOperationsAction + | FlushPatchOperationAction; diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts index 5e00027edb9..8eefacc518a 100644 --- a/src/app/core/json-patch/json-patch-operations.reducer.ts +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -12,7 +12,7 @@ import { CommitPatchOperationsAction, StartTransactionPatchOperationsAction, RollbacktPatchOperationsAction, - DeletePendingJsonPatchOperationsAction + DeletePendingJsonPatchOperationsAction, FlushPatchOperationAction } from './json-patch-operations.actions'; import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; @@ -71,7 +71,7 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp } case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS: { - return flushOperation(state, action as FlushPatchOperationsAction); + return flushOperations(state, action as FlushPatchOperationsAction); } case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: { @@ -106,6 +106,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction); } + case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATION: { + return flushOperation(state, action as FlushPatchOperationAction); + } + default: { return state; } @@ -197,6 +201,39 @@ function deletePendingOperations(state: JsonPatchOperationsState, action: Delete return null; } +/** + * Flush one operation from JsonPatchOperationsState. + * + * @param state + * the current state + * @param action + * an FlushPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationAction): JsonPatchOperationsState { + const payload = action.payload; + if (state[payload.resourceType] && state[payload.resourceType].children) { + const body = state[payload.resourceType].children[payload.resourceId].body; + const operation = body.filter(operations => operations.operation.path === payload.path)[0]; + const operationIndex = body.indexOf(operation); + const newBody = [...body]; + newBody.splice(operationIndex, 1); + + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, { + children: { + [action.payload.resourceId]: { + body: newBody, + } + }, + }) + }); + } else { + return state; + } +} + /** * Add new JSON patch operation list. * @@ -273,7 +310,7 @@ function hasValidBody(state: JsonPatchOperationsState, resourceType: any, resour * @return SubmissionObjectState * the new state, with the section new validity status. */ -function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState { +function flushOperations(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState { if (hasValue(state[ action.payload.resourceType ])) { let newChildren; if (isNotUndefined(action.payload.resourceId)) { @@ -351,7 +388,28 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target newBody.push(makeOperationEntry({ op: JsonPatchOperationType.move, from: fromPath, path: targetPath })); break; } - return newBody; + return dedupeOperationEntries(newBody); +} + +/** + * Dedupe operation entries by op and path. This prevents processing unnecessary patches in a single PATCH request. + * + * @param body JSON patch operation object entries + * @returns deduped JSON patch operation object entries + */ +function dedupeOperationEntries(body: JsonPatchOperationObject[]): JsonPatchOperationObject[] { + const ops = new Map(); + for (let i = body.length - 1; i >= 0; i--) { + const patch = body[i].operation; + const key = `${patch.op}-${patch.path}`; + if (!ops.has(key)) { + ops.set(key, patch); + } else { + body.splice(i, 1); + } + } + + return body; } function makeOperationEntry(operation) { diff --git a/src/app/core/notifications/models/suggestion-objects.resource-type.ts b/src/app/core/notifications/models/suggestion-objects.resource-type.ts new file mode 100644 index 00000000000..8f83d86376b --- /dev/null +++ b/src/app/core/notifications/models/suggestion-objects.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for the Suggestion object + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUGGESTION = new ResourceType('suggestion'); diff --git a/src/app/core/notifications/models/suggestion-source-object.resource-type.ts b/src/app/core/notifications/models/suggestion-source-object.resource-type.ts new file mode 100644 index 00000000000..e319ed5109b --- /dev/null +++ b/src/app/core/notifications/models/suggestion-source-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for the Suggestion Source object + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUGGESTION_SOURCE = new ResourceType('suggestionsource'); diff --git a/src/app/core/notifications/models/suggestion-source.model.ts b/src/app/core/notifications/models/suggestion-source.model.ts new file mode 100644 index 00000000000..12d9d7e9d84 --- /dev/null +++ b/src/app/core/notifications/models/suggestion-source.model.ts @@ -0,0 +1,47 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { SUGGESTION_SOURCE } from './suggestion-source-object.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import {CacheableObject} from '../../cache/cacheable-object.model'; + +/** + * The interface representing the Suggestion Source model + */ +@typedObject +export class SuggestionSource implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = SUGGESTION_SOURCE; + + /** + * The Suggestion Target id + */ + @autoserialize + id: string; + + /** + * The total number of suggestions provided by Suggestion Target for + */ + @autoserialize + total: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + suggestiontargets: HALLink + }; +} diff --git a/src/app/core/notifications/models/suggestion-target-object.resource-type.ts b/src/app/core/notifications/models/suggestion-target-object.resource-type.ts new file mode 100644 index 00000000000..81b1b5c2619 --- /dev/null +++ b/src/app/core/notifications/models/suggestion-target-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for the Suggestion Target object + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUGGESTION_TARGET = new ResourceType('suggestiontarget'); diff --git a/src/app/core/notifications/models/suggestion-target.model.ts b/src/app/core/notifications/models/suggestion-target.model.ts new file mode 100644 index 00000000000..99d9a8628ad --- /dev/null +++ b/src/app/core/notifications/models/suggestion-target.model.ts @@ -0,0 +1,61 @@ +import { autoserialize, deserialize } from 'cerialize'; + + +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { SUGGESTION_TARGET } from './suggestion-target-object.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; + +/** + * The interface representing the Suggestion Target model + */ +@typedObject +export class SuggestionTarget implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = SUGGESTION_TARGET; + + /** + * The Suggestion Target id + */ + @autoserialize + id: string; + + /** + * The Suggestion Target name to display + */ + @autoserialize + display: string; + + /** + * The Suggestion Target source to display + */ + @autoserialize + source: string; + + /** + * The total number of suggestions provided by Suggestion Target for + */ + @autoserialize + total: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + suggestions: HALLink, + target: HALLink + }; +} diff --git a/src/app/core/notifications/models/suggestion.model.ts b/src/app/core/notifications/models/suggestion.model.ts new file mode 100644 index 00000000000..ad58b1cfe55 --- /dev/null +++ b/src/app/core/notifications/models/suggestion.model.ts @@ -0,0 +1,88 @@ +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; + +import { SUGGESTION } from './suggestion-objects.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; +import {CacheableObject} from '../../cache/cacheable-object.model'; + +/** + * The interface representing Suggestion Evidences such as scores (authorScore, datescore) + */ +export interface SuggestionEvidences { + [sectionId: string]: { + score: string; + notes: string + }; +} +/** + * The interface representing the Suggestion Source model + */ +@typedObject +export class Suggestion implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = SUGGESTION; + + /** + * The Suggestion id + */ + @autoserialize + id: string; + + /** + * The Suggestion name to display + */ + @autoserialize + display: string; + + /** + * The Suggestion source to display + */ + @autoserialize + source: string; + + /** + * The Suggestion external source uri + */ + @autoserialize + externalSourceUri: string; + + /** + * The Total Score of the suggestion + */ + @autoserialize + score: string; + + /** + * The total number of suggestions provided by Suggestion Target for + */ + @autoserialize + evidences: SuggestionEvidences; + + /** + * All metadata of this suggestion object + */ + @excludeFromEquals + @autoserializeAs(MetadataMapSerializer) + metadata: MetadataMap; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + target: HALLink + }; +} diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts new file mode 100644 index 00000000000..6ab60ef2de7 --- /dev/null +++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts @@ -0,0 +1,248 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { QualityAssuranceEventDataService } from './quality-assurance-event-data.service'; +import { + qualityAssuranceEventObjectMissingPid, + qualityAssuranceEventObjectMissingPid2, + qualityAssuranceEventObjectMissingProjectFound +} from '../../../../shared/mocks/notifications.mock'; +import { ReplaceOperation } from 'fast-json-patch'; +import { RequestEntry } from '../../../data/request-entry.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; + +describe('QualityAssuranceEventDataService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceEventDataService; + let serviceASAny: any; + let responseCacheEntry: RequestEntry; + let responseCacheEntryB: RequestEntry; + let responseCacheEntryC: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheServiceStub; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancetopics'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + const topic = 'ENRICH!MORE!PID'; + + const pageInfo = new PageInfo(); + const array = [qualityAssuranceEventObjectMissingPid, qualityAssuranceEventObjectMissingPid2]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaEventObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid); + const qaEventObjectMissingProjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + const status = 'ACCEPTED'; + const operation: ReplaceOperation[] = [ + { + path: '/status', + op: 'replace', + value: status + } + ]; + + beforeEach(() => { + scheduler = getTestScheduler(); + + 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: jasmine.createSpy('getByHref'), + getByUUID: jasmine.createSpy('getByUUID') + }); + + responseCacheEntryB = new RequestEntry(); + responseCacheEntryB.request = { href: 'https://rest.api/' } as any; + responseCacheEntryB.response = new RestResponse(true, 201, 'Created'); + + responseCacheEntryC = new RequestEntry(); + responseCacheEntryC.request = { href: 'https://rest.api/' } as any; + responseCacheEntryC.response = new RestResponse(true, 204, 'No Content'); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaEventObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'), + buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait') + }); + + objectCache = new ObjectCacheServiceStub(); + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceEventDataService( + requestService, + rdbService, + objectCache as ObjectCacheService, + halService, + notificationsService, + comparator + ); + + serviceASAny = service; + + spyOn(serviceASAny.searchData, 'searchBy').and.callThrough(); + spyOn(serviceASAny, 'findById').and.callThrough(); + spyOn(serviceASAny.patchData, 'patch').and.callThrough(); + spyOn(serviceASAny, 'postOnRelated').and.callThrough(); + spyOn(serviceASAny, 'deleteOnRelated').and.callThrough(); + }); + + describe('getEventsByTopic', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to searchData.searchBy', () => { + const options: FindListOptions = { + searchParams: [ + { + fieldName: 'topic', + fieldValue: topic + } + ] + }; + service.getEventsByTopic(topic); + expect(serviceASAny.searchData.searchBy).toHaveBeenCalledWith('findByTopic', options, true, true); + }); + + it('should return a RemoteData> for the object with the given Topic', () => { + const result = service.getEventsByTopic(topic); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEvent', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should call findById', () => { + service.getEvent(qualityAssuranceEventObjectMissingPid.id).subscribe( + (res) => { + expect(serviceASAny.findById).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid.id, true, true); + } + ); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getEvent(qualityAssuranceEventObjectMissingPid.id); + const expected = cold('(a)', { + a: qaEventObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('patchEvent', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + serviceASAny.rdbService.buildFromRequestUUIDAndAwait.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to patchData.patch', () => { + service.patchEvent(status, qualityAssuranceEventObjectMissingPid).subscribe( + (res) => { + expect(serviceASAny.patchData.patch).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid, operation); + } + ); + }); + + it('should return a RemoteData with HTTP 200', () => { + const result = service.patchEvent(status, qualityAssuranceEventObjectMissingPid); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid) + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('boundProject', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryB)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryB)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectMissingProjectRD)); + }); + + it('should call postOnRelated', () => { + service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID).subscribe( + (res) => { + expect(serviceASAny.postOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID); + } + ); + }); + + it('should return a RestResponse with HTTP 201', () => { + const result = service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound) + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('removeProject', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryC)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryC)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(createSuccessfulRemoteDataObject({}))); + }); + + it('should call deleteOnRelated', () => { + service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id).subscribe( + (res) => { + expect(serviceASAny.deleteOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id); + } + ); + }); + + it('should return a RestResponse with HTTP 204', () => { + const result = service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject({}) + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts new file mode 100644 index 00000000000..e266ace080b --- /dev/null +++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts @@ -0,0 +1,252 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find, switchMap, take } from 'rxjs/operators'; +import { ReplaceOperation } from 'fast-json-patch'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../data/base/data-service.decorator'; +import { RequestService } from '../../../data/request.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceEventObject } from '../models/quality-assurance-event.model'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from '../models/quality-assurance-event-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { NoContent } from '../../../shared/NoContent.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { CreateData, CreateDataImpl } from '../../../data/base/create-data'; +import { PatchData, PatchDataImpl } from '../../../data/base/patch-data'; +import { DeleteData, DeleteDataImpl } from '../../../data/base/delete-data'; +import { SearchData, SearchDataImpl } from '../../../data/base/search-data'; +import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; +import { hasValue } from '../../../../shared/empty.util'; +import { DeleteByIDRequest, PostRequest } from '../../../data/request.models'; +import { HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpOptions } from '../../../dspace-rest/dspace-rest.service'; +import { + QualityAssuranceEventData +} from '../../../../notifications/qa/project-entry-import-modal/project-entry-import-modal.component'; + +/** + * The service handling all Quality Assurance topic REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_EVENT_OBJECT) +export class QualityAssuranceEventDataService extends IdentifiableDataService { + + private createData: CreateData; + private searchData: SearchData; + private patchData: PatchData; + private deleteData: DeleteData; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {DefaultChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: DefaultChangeAnalyzer + ) { + super('qualityassuranceevents', requestService, rdbService, objectCache, halService); + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, 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, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return the list of Quality Assurance events by topic. + * + * @param topic + * The Quality Assurance topic + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Quality Assurance events. + */ + public getEventsByTopic(topic: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { + options.searchParams = [ + { + fieldName: 'topic', + fieldValue: topic + } + ]; + return this.searchData.searchBy('findByTopic', options, true, true, ...linksToFollow); + } + + /** + * Service for retrieving Quality Assurance events by topic and target. + * @param options (Optional) The search options to use when retrieving the events. + * @param linksToFollow (Optional) The links to follow when retrieving the events. + * @returns An observable of the remote data containing the paginated list of Quality Assurance events. + */ + public searchEventsByTopic(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy('findByTopic', options, true, true, ...linksToFollow); + } + + /** + * Clear findByTopic requests from cache + */ + public clearFindByTopicRequests() { + this.requestService.setStaleByHrefSubstring('findByTopic'); + } + + /** + * Return a single Quality Assurance event. + * + * @param id + * The Quality Assurance event id + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return Observable> + * The Quality Assurance event. + */ + public getEvent(id: string, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, true, true, ...linksToFollow); + } + + /** + * Save the new status of a Quality Assurance event. + * + * @param status + * The new status + * @param dso QualityAssuranceEventObject + * The event item + * @param reason + * The optional reason (not used for now; for future implementation) + * @return Observable + * The REST response. + */ + public patchEvent(status, dso, reason?: string): Observable> { + const operation: ReplaceOperation[] = [ + { + path: '/status', + op: 'replace', + value: status + } + ]; + return this.patchData.patch(dso, operation); + } + + /** + * Bound a project to a Quality Assurance event publication. + * + * @param itemId + * The Id of the Quality Assurance event + * @param projectId + * The project Id to bound + * @return Observable + * The REST response. + */ + public boundProject(itemId: string, projectId: string): Observable> { + return this.postOnRelated(itemId, projectId); + } + + /** + * Remove a project from a Quality Assurance event publication. + * + * @param itemId + * The Id of the Quality Assurance event + * @return Observable + * The REST response. + */ + public removeProject(itemId: string): Observable> { + return this.deleteOnRelated(itemId); + } + + /** + * Perform a delete operation on an endpoint related item. Ex.: endpoint//related + * @param objectId The item id + * @return the RestResponse as an Observable + */ + private deleteOnRelated(objectId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getIDHrefObs(objectId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new DeleteByIDRequest(requestId, href + '/related', objectId); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item= + * @param objectId The item id + * @param relatedItemId The related item Id + * @param body The optional POST body + * @return the RestResponse as an Observable + */ + private postOnRelated(objectId: string, relatedItemId: string, body?: any) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(objectId); + + hrefObs.pipe( + take(1) + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a post on an endpoint related to correction type + * @param data the data to post + * @returns the RestResponse as an Observable + */ + postData(target: string, correctionType: string, related: string, reason: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const href$ = this.getBrowseEndpoint(); + + return href$.pipe( + switchMap((href: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + let params = new HttpParams(); + params = params.append('target', target) + .append('correctionType', correctionType); + options.params = params; + const request = new PostRequest(requestId, href, {'reason': reason} , options); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + return this.rdbService.buildFromRequestUUID(requestId); + }) + ); + } + + public deleteQAEvent(qaEvent: QualityAssuranceEventData): Observable> { + return this.deleteData.delete(qaEvent.id); + } + +} diff --git a/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts new file mode 100644 index 00000000000..84aff6ba2cf --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance event + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_EVENT_OBJECT = new ResourceType('qualityassuranceevent'); diff --git a/src/app/core/notifications/qa/models/quality-assurance-event.model.ts b/src/app/core/notifications/qa/models/quality-assurance-event.model.ts new file mode 100644 index 00000000000..1d66e5bb28a --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-event.model.ts @@ -0,0 +1,173 @@ +/* eslint-disable max-classes-per-file */ +import { Observable } from 'rxjs'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from './quality-assurance-event-object.resource-type'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { Item } from '../../../shared/item.model'; +import { ITEM } from '../../../shared/item.resource-type'; +import { link, typedObject } from '../../../cache/builders/build-decorators'; +import { RemoteData } from '../../../data/remote-data'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance event message + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface QualityAssuranceEventMessageObject { + +} + +/** + * The interface representing the Quality Assurance event message + */ +export interface SourceQualityAssuranceEventMessageObject { + /** + * The type of 'value' + */ + type: string; + + reason: string; + + /** + * The value suggested by Notifications + */ + value: string; + + /** + * The abstract suggested by Notifications + */ + abstract: string; + + /** + * The project acronym suggested by Notifications + */ + acronym: string; + + /** + * The project code suggested by Notifications + */ + code: string; + + /** + * The project funder suggested by Notifications + */ + funder: string; + + /** + * The project program suggested by Notifications + */ + fundingProgram?: string; + + /** + * The project jurisdiction suggested by Notifications + */ + jurisdiction: string; + + /** + * The project title suggested by Notifications + */ + title: string; + + /** + * The Source ID. + */ + sourceId: string; + + /** + * The PID href. + */ + pidHref: string; + +} + +/** + * The interface representing the Quality Assurance event model + */ +@typedObject +export class QualityAssuranceEventObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_EVENT_OBJECT; + + /** + * The Quality Assurance event uuid inside DSpace + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this Quality Assurance event + */ + @autoserializeAs(String, 'id') + uuid: string; + + /** + * The Quality Assurance event original id (ex.: the source archive OAI-PMH identifier) + */ + @autoserialize + originalId: string; + + /** + * The title of the article to which the suggestion refers + */ + @autoserialize + title: string; + + /** + * Reliability of the suggestion (of the data inside 'message') + */ + @autoserialize + trust: number; + + /** + * The timestamp Quality Assurance event was saved in DSpace + */ + @autoserialize + eventDate: string; + + /** + * The Quality Assurance event status (ACCEPTED, REJECTED, DISCARDED, PENDING) + */ + @autoserialize + status: string; + + /** + * The suggestion data. Data may vary depending on the source + */ + @autoserialize + message: SourceQualityAssuranceEventMessageObject; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + target: HALLink, + related: HALLink + }; + + /** + * The related publication DSpace item + * Will be undefined unless the {@item HALLink} has been resolved. + */ + @link(ITEM) + target?: Observable>; + + /** + * The related project for this Event + * Will be undefined unless the {@related HALLink} has been resolved. + */ + @link(ITEM) + related?: Observable>; +} diff --git a/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts new file mode 100644 index 00000000000..b4f64b24d14 --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance source + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_SOURCE_OBJECT = new ResourceType('qualityassurancesource'); diff --git a/src/app/core/notifications/qa/models/quality-assurance-source.model.ts b/src/app/core/notifications/qa/models/quality-assurance-source.model.ts new file mode 100644 index 00000000000..f59467384ff --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-source.model.ts @@ -0,0 +1,52 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from './quality-assurance-source-object.resource-type'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance source model + */ +@typedObject +export class QualityAssuranceSourceObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_SOURCE_OBJECT; + + /** + * The Quality Assurance source id + */ + @autoserialize + id: string; + + /** + * The date of the last udate from Notifications + */ + @autoserialize + lastEvent: string; + + /** + * The total number of suggestions provided by Notifications for this source + */ + @autoserialize + totalEvents: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts new file mode 100644 index 00000000000..e9fc57a307c --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance topic + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_TOPIC_OBJECT = new ResourceType('qualityassurancetopic'); diff --git a/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts b/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts new file mode 100644 index 00000000000..529980e5f7c --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts @@ -0,0 +1,58 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from './quality-assurance-topic-object.resource-type'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance topic model + */ +@typedObject +export class QualityAssuranceTopicObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_TOPIC_OBJECT; + + /** + * The Quality Assurance topic id + */ + @autoserialize + id: string; + + /** + * The Quality Assurance topic name to display + */ + @autoserialize + name: string; + + /** + * The date of the last udate from Notifications + */ + @autoserialize + lastEvent: string; + + /** + * The total number of suggestions provided by Notifications for this topic + */ + @autoserialize + totalEvents: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts new file mode 100644 index 00000000000..105303d1f9b --- /dev/null +++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts @@ -0,0 +1,126 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../../shared/mocks/notifications.mock'; +import { RequestEntry } from '../../../data/request-entry.model'; +import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; + +describe('QualityAssuranceSourceDataService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceSourceDataService; + let responseCacheEntry: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheServiceStub; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancesources'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + + const pageInfo = new PageInfo(); + const array = [qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaSourceObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceSourceObjectMorePid); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaSourceObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + }); + + objectCache = new ObjectCacheServiceStub(); + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceSourceDataService( + requestService, + rdbService, + objectCache as ObjectCacheService, + halService, + notificationsService + ); + + spyOn((service as any).findAllData, 'findAll').and.callThrough(); + spyOn((service as any), 'findById').and.callThrough(); + }); + + describe('getSources', () => { + it('should call findAll', (done) => { + service.getSources().subscribe( + (res) => { + expect((service as any).findAllData.findAll).toHaveBeenCalledWith({}, true, true); + } + ); + done(); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getSources(); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getSource', () => { + it('should call findById', (done) => { + service.getSource(qualityAssuranceSourceObjectMorePid.id).subscribe( + (res) => { + expect((service as any).findById).toHaveBeenCalledWith(qualityAssuranceSourceObjectMorePid.id, true, true); + } + ); + done(); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getSource(qualityAssuranceSourceObjectMorePid.id); + const expected = cold('(a)', { + a: qaSourceObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts new file mode 100644 index 00000000000..f6a58fdd45f --- /dev/null +++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../data/base/data-service.decorator'; +import { RequestService } from '../../../data/request.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceSourceObject } from '../models/quality-assurance-source.model'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from '../models/quality-assurance-source-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data'; +import { SearchData, SearchDataImpl } from '../../../data/base/search-data'; + +/** + * The service handling all Quality Assurance source REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_SOURCE_OBJECT) +export class QualityAssuranceSourceDataService extends IdentifiableDataService { + + private findAllData: FindAllData; + private searchAllData: SearchData; + + private searchByTargetMethod = 'byTarget'; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super('qualityassurancesources', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchAllData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return the list of Quality Assurance source. + * + * @param options Find list options 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>> + * The list of Quality Assurance source. + */ + public getSources(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Clear FindAll source requests from cache + */ + public clearFindAllSourceRequests() { + this.requestService.setStaleByHrefSubstring('qualityassurancesources'); + } + + /** + * Return a single Quality Assurance source. + * + * @param id The Quality Assurance source id + * @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> The Quality Assurance source. + */ + public getSource(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Retrieves a paginated list of QualityAssuranceSourceObject objects that are associated with a given target object. + * @param options The options for the search query. + * @param useCachedVersionIfAvailable Whether to use a cached version of the data if available. + * @param reRequestOnStale Whether to re-request the data if the cached version is stale. + * @param linksToFollow The links to follow to retrieve the data. + * @returns An observable that emits a RemoteData object containing the paginated list of QualityAssuranceSourceObject objects. + */ + public getSourcesByTarget(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchAllData.searchBy(this.searchByTargetMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts new file mode 100644 index 00000000000..bade6cace57 --- /dev/null +++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts @@ -0,0 +1,133 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { QualityAssuranceTopicDataService } from './quality-assurance-topic-data.service'; +import { + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../../shared/mocks/notifications.mock'; +import { RequestEntry } from '../../../data/request-entry.model'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; + +describe('QualityAssuranceTopicDataService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceTopicDataService; + let responseCacheEntry: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheServiceStub; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancetopics'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + + const pageInfo = new PageInfo(); + const array = [qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaTopicObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceTopicObjectMorePid); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaTopicObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + }); + + objectCache = new ObjectCacheServiceStub(); + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceTopicDataService( + requestService, + rdbService, + objectCache as ObjectCacheService, + halService, + notificationsService + ); + + spyOn((service as any).findAllData, 'findAll').and.callThrough(); + spyOn((service as any), 'findById').and.callThrough(); + spyOn((service as any).searchData, 'searchBy').and.callThrough(); + }); + + describe('searchTopicsByTarget', () => { + it('should call searchData.searchBy with the correct parameters', () => { + const options = { elementsPerPage: 10 }; + const useCachedVersionIfAvailable = true; + const reRequestOnStale = true; + + service.searchTopicsByTarget(options, useCachedVersionIfAvailable, reRequestOnStale); + + expect((service as any).searchData.searchBy).toHaveBeenCalledWith( + 'byTarget', + options, + useCachedVersionIfAvailable, + reRequestOnStale + ); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.searchTopicsByTarget(); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getTopic', () => { + it('should call findByHref', (done) => { + service.getTopic(qualityAssuranceTopicObjectMorePid.id).subscribe( + (res) => { + expect((service as any).findById).toHaveBeenCalledWith(qualityAssuranceTopicObjectMorePid.id, true, true); + } + ); + done(); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getTopic(qualityAssuranceTopicObjectMorePid.id); + const expected = cold('(a)', { + a: qaTopicObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts new file mode 100644 index 00000000000..919aaac71a9 --- /dev/null +++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RequestService } from '../../../data/request.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceTopicObject } from '../models/quality-assurance-topic.model'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { dataService } from '../../../data/base/data-service.decorator'; +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from '../models/quality-assurance-topic-object.resource-type'; +import { SearchData, SearchDataImpl } from '../../../data/base/search-data'; +import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data'; + +/** + * The service handling all Quality Assurance topic REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_TOPIC_OBJECT) +export class QualityAssuranceTopicDataService extends IdentifiableDataService { + + private findAllData: FindAllData; + private searchData: SearchData; + + private searchByTargetMethod = 'byTarget'; + private searchBySourceMethod = 'bySource'; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super('qualityassurancetopics', 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); + } + + /** + * Search for Quality Assurance topics. + * @param options The search options. + * @param useCachedVersionIfAvailable Whether to use cached version if available. + * @param reRequestOnStale Whether to re-request on stale. + * @param linksToFollow The links to follow. + * @returns An observable of remote data containing a paginated list of Quality Assurance topics. + */ + public searchTopicsByTarget(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(this.searchByTargetMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Searches for quality assurance topics by source. + * @param options The search options. + * @param useCachedVersionIfAvailable Whether to use a cached version if available. + * @param reRequestOnStale Whether to re-request the data if it's stale. + * @param linksToFollow The links to follow. + * @returns An observable of the remote data containing the paginated list of quality assurance topics. + */ + public searchTopicsBySource(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(this.searchBySourceMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Clear FindAll topics requests from cache + */ + public clearFindAllTopicsRequests() { + this.requestService.setStaleByHrefSubstring('qualityassurancetopics'); + } + + /** + * Return a single Quality Assurance topic. + * + * @param id The Quality Assurance topic id + * @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> + * The Quality Assurance topic. + */ + public getTopic(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/notifications/source/suggestion-source-data.service.ts b/src/app/core/notifications/source/suggestion-source-data.service.ts new file mode 100644 index 00000000000..f00a84c95b5 --- /dev/null +++ b/src/app/core/notifications/source/suggestion-source-data.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../../data/base/data-service.decorator'; +import { SUGGESTION_SOURCE } from '../models/suggestion-source-object.resource-type'; +import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; +import { SuggestionSource } from '../models/suggestion-source.model'; +import { FindAllData, FindAllDataImpl } from '../../data/base/find-all-data'; +import { Store } from '@ngrx/store'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core-state.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; + +/** + * Service that retrieves Suggestion Source data + */ +@Injectable() +@dataService(SUGGESTION_SOURCE) +export class SuggestionSourceDataService extends IdentifiableDataService { + + protected linkPath = 'suggestionsources'; + + private findAllData: FindAllData; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super('suggestionsources', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + /** + * Return the list of Suggestion source. + * + * @param options Find list options 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>> + * The list of Quality Assurance source. + */ + public getSources(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Return a single Suggestoin source. + * + * @param id The Quality Assurance source id + * @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> The Quality Assurance source. + */ + public getSource(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options 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 object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/notifications/source/suggestions-source-data.service.spec.ts b/src/app/core/notifications/source/suggestions-source-data.service.spec.ts new file mode 100644 index 00000000000..28f34b863d8 --- /dev/null +++ b/src/app/core/notifications/source/suggestions-source-data.service.spec.ts @@ -0,0 +1,115 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core-state.model'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindAllData } from '../../data/base/find-all-data'; +import { GetRequest } from '../../data/request.models'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { SuggestionSourceDataService } from './suggestion-source-data.service'; +import { SuggestionSource } from '../models/suggestion-source.model'; + +describe('SuggestionSourceDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionSourceDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: DefaultChangeAnalyzer; + let responseCacheEntry: RequestEntry; + + const store = {} as Store; + const endpointURL = `https://rest.api/rest/api/suggestionsources`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionSourceDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparator = {} as DefaultChangeAnalyzer; + 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 SuggestionSourceDataService(null, null, null, null, null, null, null, null) as unknown as FindAllData; + testFindAllDataImplementation(initFindAllService); + }); + + describe('getSources', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}`); + scheduler.schedule(() => service.getSources().subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getSource', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/testId`); + scheduler.schedule(() => service.getSource('testId').subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); +}); diff --git a/src/app/core/notifications/suggestion-data.service.spec.ts b/src/app/core/notifications/suggestion-data.service.spec.ts new file mode 100644 index 00000000000..c0bc97ea129 --- /dev/null +++ b/src/app/core/notifications/suggestion-data.service.spec.ts @@ -0,0 +1,173 @@ +import { TestScheduler } from 'rxjs/testing'; +import { SuggestionDataServiceImpl, SuggestionsDataService } from './suggestions-data.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Suggestion } from './models/suggestion.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RequestEntry } from '../data/request-entry.model'; +import { RestResponse } from '../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteData } from '../data/remote-data'; +import { RequestEntryState } from '../data/request-entry-state.model'; +import { SuggestionSource } from './models/suggestion-source.model'; +import { SuggestionTarget } from './models/suggestion-target.model'; +import { SuggestionSourceDataService } from './source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from './target/suggestion-target-data.service'; +import { RequestParam } from '../cache/models/request-param.model'; + +describe('SuggestionDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionsDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparatorSuggestion: DefaultChangeAnalyzer; + let comparatorSuggestionSource: DefaultChangeAnalyzer; + let comparatorSuggestionTarget: DefaultChangeAnalyzer; + let suggestionSourcesDataService: SuggestionSourceDataService; + let suggestionTargetsDataService: SuggestionTargetDataService; + let suggestionsDataService: SuggestionDataServiceImpl; + let responseCacheEntry: RequestEntry; + + + const testSource = 'test-source'; + const testUserId = '1234-4321'; + const endpointURL = `https://rest.api/rest/api/`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionsDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparatorSuggestion, + comparatorSuggestionSource, + comparatorSuggestionTarget + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparatorSuggestion = {} as DefaultChangeAnalyzer; + comparatorSuggestionTarget = {} as DefaultChangeAnalyzer; + comparatorSuggestionSource = {} as DefaultChangeAnalyzer; + 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), + setStaleByHrefSubstring: observableOf(true) + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + suggestionSourcesDataService = jasmine.createSpyObj('suggestionSourcesDataService', { + getSources: observableOf(null), + }); + + suggestionTargetsDataService = jasmine.createSpyObj('suggestionTargetsDataService', { + getTargets: observableOf(null), + getTargetsByUser: observableOf(null), + findById: observableOf(null), + }); + + suggestionsDataService = jasmine.createSpyObj('suggestionsDataService', { + searchBy: observableOf(null), + delete: observableOf(null), + }); + + + service = initTestService(); + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionSourcesDataService'] = suggestionSourcesDataService; + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionTargetsDataService'] = suggestionTargetsDataService; + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionsDataService'] = suggestionsDataService; + }); + + describe('Suggestion targets service', () => { + it('should call suggestionSourcesDataService.getTargets', () => { + const options = { + searchParams: [new RequestParam('source', testSource)] + }; + service.getTargets(testSource); + expect(suggestionTargetsDataService.getTargets).toHaveBeenCalledWith('findBySource', options); + }); + + it('should call suggestionSourcesDataService.getTargetsByUser', () => { + const options = { + searchParams: [new RequestParam('target', testUserId)] + }; + service.getTargetsByUser(testUserId); + expect(suggestionTargetsDataService.getTargetsByUser).toHaveBeenCalledWith(testUserId, options); + }); + + it('should call suggestionSourcesDataService.getTargetById', () => { + service.getTargetById('1'); + expect(suggestionTargetsDataService.findById).toHaveBeenCalledWith('1'); + }); + }); + + + describe('Suggestion sources service', () => { + it('should call suggestionSourcesDataService.getSources', () => { + service.getSources(); + expect(suggestionSourcesDataService.getSources).toHaveBeenCalled(); + }); + }); + + describe('Suggestion service', () => { + it('should call suggestionsDataService.searchBy', () => { + const options = { + searchParams: [new RequestParam('target', testUserId), new RequestParam('source', testSource)] + }; + service.getSuggestionsByTargetAndSource(testUserId, testSource); + expect(suggestionsDataService.searchBy).toHaveBeenCalledWith('findByTargetAndSource', options, false, true); + }); + + it('should call suggestionsDataService.delete', () => { + service.deleteSuggestion('1'); + expect(suggestionsDataService.delete).toHaveBeenCalledWith('1'); + }); + }); + + describe('Request service', () => { + it('should call requestService.setStaleByHrefSubstring', () => { + service.clearSuggestionRequests(); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/notifications/suggestions-data.service.ts b/src/app/core/notifications/suggestions-data.service.ts new file mode 100644 index 00000000000..17b14825782 --- /dev/null +++ b/src/app/core/notifications/suggestions-data.service.ts @@ -0,0 +1,229 @@ +/* eslint-disable max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RequestService } from '../data/request.service'; +import { UpdateDataServiceImpl } from '../data/update-data.service'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { RemoteData } from '../data/remote-data'; +import { SUGGESTION } from './models/suggestion-objects.resource-type'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { SuggestionSource } from './models/suggestion-source.model'; +import { SuggestionTarget } from './models/suggestion-target.model'; +import { Suggestion } from './models/suggestion.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { NoContent } from '../shared/NoContent.model'; +import {CoreState} from '../core-state.model'; +import {FindListOptions} from '../data/find-list-options.model'; +import { SuggestionSourceDataService } from './source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from './target/suggestion-target-data.service'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ +export class SuggestionDataServiceImpl extends UpdateDataServiceImpl { + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {ChangeAnalyzer} comparator + * @param responseMsToLive + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + ) { + super('suggestions', requestService, rdbService, objectCache, halService, notificationsService, comparator ,responseMsToLive); + } +} + +/** + * The service handling all Suggestion Target REST requests. + */ +@Injectable() +@dataService(SUGGESTION) +export class SuggestionsDataService { + protected searchFindBySourceMethod = 'findBySource'; + protected searchFindByTargetAndSourceMethod = 'findByTargetAndSource'; + + /** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ + private suggestionsDataService: SuggestionDataServiceImpl; + + /** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ + private suggestionSourcesDataService: SuggestionSourceDataService; + + /** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ + private suggestionTargetsDataService: SuggestionTargetDataService; + + private responseMsToLive = 10 * 1000; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DefaultChangeAnalyzer} comparatorSuggestions + * @param {DefaultChangeAnalyzer} comparatorSources + * @param {DefaultChangeAnalyzer} comparatorTargets + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparatorSuggestions: DefaultChangeAnalyzer, + protected comparatorSources: DefaultChangeAnalyzer, + protected comparatorTargets: DefaultChangeAnalyzer, + ) { + this.suggestionsDataService = new SuggestionDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSuggestions, this.responseMsToLive); + this.suggestionSourcesDataService = new SuggestionSourceDataService(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSources); + this.suggestionTargetsDataService = new SuggestionTargetDataService(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorTargets); + } + + /** + * Return the list of Suggestion Sources + * + * @param options + * Find list options object. + * @return Observable>> + * The list of Suggestion Sources. + */ + public getSources(options: FindListOptions = {}): Observable>> { + return this.suggestionSourcesDataService.getSources(options); + } + + /** + * Return the list of Suggestion Target for a given source + * + * @param source + * The source for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargets( + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('source', source)]; + + return this.suggestionTargetsDataService.getTargets(this.searchFindBySourceMethod, options, ...linksToFollow); + } + + /** + * Return the list of Suggestion Target for a given user + * + * @param userId + * The user Id for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargetsByUser( + userId: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('target', userId)]; + return this.suggestionTargetsDataService.getTargetsByUser(userId, options, ...linksToFollow); + } + + /** + * Return a Suggestion Target for a given id + * + * @param targetId + * The target id to retrieve. + * + * @return Observable> + * The list of Suggestion Target. + */ + public getTargetById(targetId: string): Observable> { + return this.suggestionTargetsDataService.findById(targetId); + } + + /** + * Used to delete Suggestion + * @suggestionId + */ + public deleteSuggestion(suggestionId: string): Observable> { + return this.suggestionsDataService.delete(suggestionId); + } + + /** + * Return the list of Suggestion for a given target and source + * + * @param target + * The target for which to find suggestions. + * @param source + * The source for which to find suggestions. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion. + */ + public getSuggestionsByTargetAndSource( + target: string, + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [ + new RequestParam('target', target), + new RequestParam('source', source) + ]; + + return this.suggestionsDataService.searchBy(this.searchFindByTargetAndSourceMethod, options, false, true, ...linksToFollow); + } + + /** + * Clear findByTargetAndSource suggestions requests from cache + */ + public clearSuggestionRequests() { + this.requestService.setStaleByHrefSubstring(this.searchFindByTargetAndSourceMethod); + } +} diff --git a/src/app/core/notifications/target/suggestion-target-data.service.ts b/src/app/core/notifications/target/suggestion-target-data.service.ts new file mode 100644 index 00000000000..a2f1507b100 --- /dev/null +++ b/src/app/core/notifications/target/suggestion-target-data.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../../data/base/data-service.decorator'; + +import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; +import { SuggestionTarget } from '../models/suggestion-target.model'; +import { FindAllData, FindAllDataImpl } from '../../data/base/find-all-data'; +import { Store } from '@ngrx/store'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core-state.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { SearchData, SearchDataImpl } from '../../data/base/search-data'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { SUGGESTION_TARGET } from '../models/suggestion-target-object.resource-type'; + +@Injectable() +@dataService(SUGGESTION_TARGET) +export class SuggestionTargetDataService extends IdentifiableDataService { + + protected linkPath = 'suggestiontargets'; + private findAllData: FindAllData; + private searchData: SearchData; + protected searchFindBySourceMethod = 'findBySource'; + protected searchFindByTargetMethod = 'findByTarget'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super('suggestiontargets', 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); + } + /** + * Return the list of Suggestion Target for a given source + * + * @param source + * The source for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargets( + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('source', source)]; + + return this.searchBy(this.searchFindBySourceMethod, options, true, true, ...linksToFollow); + } + + /** + * Return the list of Suggestion Target for a given user + * + * @param userId + * The user Id for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargetsByUser( + userId: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('target', userId)]; + + return this.searchBy(this.searchFindByTargetMethod, options, true, true, ...linksToFollow); + } + /** + * Return a Suggestion Target for a given id + * + * @param targetId + * The target id to retrieve. + * + * @return Observable> + * The list of Suggestion Target. + */ + public getTargetById(targetId: string): Observable> { + return this.findById(targetId); + } + + /** + * 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); + } + + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options 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 object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/notifications/target/suggestions-target-data.service.spec.ts b/src/app/core/notifications/target/suggestions-target-data.service.spec.ts new file mode 100644 index 00000000000..9207603a5ad --- /dev/null +++ b/src/app/core/notifications/target/suggestions-target-data.service.spec.ts @@ -0,0 +1,138 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core-state.model'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SearchData } from '../../data/base/search-data'; +import { testSearchDataImplementation } from '../../data/base/search-data.spec'; +import { SuggestionTargetDataService } from './suggestion-target-data.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { SuggestionTarget } from '../models/suggestion-target.model'; +import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindAllData } from '../../data/base/find-all-data'; +import { GetRequest } from '../../data/request.models'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntryState } from '../../data/request-entry-state.model'; + +describe('SuggestionTargetDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionTargetDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: DefaultChangeAnalyzer; + let responseCacheEntry: RequestEntry; + + const store = {} as Store; + const endpointURL = `https://rest.api/rest/api/suggestiontargets`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionTargetDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparator = {} as DefaultChangeAnalyzer; + 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 initSearchService = () => new SuggestionTargetDataService(null, null, null, null, null, null, null, null) as unknown as SearchData; + const initFindAllService = () => new SuggestionTargetDataService(null, null, null, null, null, null, null, null) as unknown as FindAllData; + testSearchDataImplementation(initSearchService); + testFindAllDataImplementation(initFindAllService); + }); + + describe('getTargetById', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), endpointURL + '/testId'); + scheduler.schedule(() => service.getTargetById('testId').subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getTargetsByUser', () => { + it('should send a new GetRequest', () => { + const options = { + searchParams: [new RequestParam('target', 'testId')] + }; + const searchFindByTargetMethod = 'findByTarget'; + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/search/${searchFindByTargetMethod}?target=testId`); + scheduler.schedule(() => service.getTargetsByUser('testId', options).subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getTargets', () => { + it('should send a new GetRequest', () => { + const options = { + searchParams: [new RequestParam('source', 'testId')] + }; + const searchFindBySourceMethod = 'findBySource'; + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/search/${searchFindBySourceMethod}?source=testId`); + scheduler.schedule(() => service.getTargets('testId', options).subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); +}); diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index b4c02bee634..dcebf5794cc 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -39,4 +39,6 @@ export enum Context { MyDSpaceValidation = 'mydspaceValidation', Bitstream = 'bitstream', + + CoarNotify = 'coarNotify', } diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index a27cb6954bc..9c2f499b3d9 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -7,4 +7,5 @@ export enum ViewMode { GridElement = 'grid', DetailedListElement = 'detailed', StandalonePage = 'standalone', + Table = 'table', } diff --git a/src/app/core/submission/correctiontype-data.service.ts b/src/app/core/submission/correctiontype-data.service.ts new file mode 100644 index 00000000000..8a5bbb1fb8f --- /dev/null +++ b/src/app/core/submission/correctiontype-data.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; + +import { dataService } from '../data/base/data-service.decorator'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { SearchDataImpl } from '../data/base/search-data'; +import { CorrectionType } from './models/correctiontype.model'; +import { Observable, map } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { getAllSucceededRemoteDataPayload, getPaginatedListPayload } from '../shared/operators'; + +/** + * A service that provides methods to make REST requests with correctiontypes endpoint. + */ +@Injectable() +@dataService(CorrectionType.type) +export class CorrectionTypeDataService extends IdentifiableDataService { + protected linkPath = 'correctiontypes'; + protected searchByTopic = 'findByTopic'; + protected searchFindByItem = 'findByItem'; + private searchData: SearchDataImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('correctiontypes', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Get the correction type by id + * @param id the id of the correction type + * @param useCachedVersionIfAvailable use the cached version if available + * @param reRequestOnStale re-request on stale + * @returns {Observable>} the correction type + */ + getCorrectionTypeById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale); + } + + /** + * Search for the correction types for the item + * @param itemUuid the uuid of the item + * @param useCachedVersionIfAvailable use the cached version if available + * @returns the list of correction types for the item + */ + findByItem(itemUuid: string, useCachedVersionIfAvailable): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', itemUuid)]; + return this.searchData.searchBy(this.searchFindByItem, options, useCachedVersionIfAvailable); + } + + /** + * Find the correction type for the topic + * @param topic the topic of the correction type to search for + * @param useCachedVersionIfAvailable use the cached version if available + * @param reRequestOnStale re-request on stale + * @returns the correction type for the topic + */ + findByTopic(topic: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { + const options = new FindListOptions(); + options.searchParams = [ + { + fieldName: 'topic', + fieldValue: topic, + }, + ]; + + return this.searchData.searchBy(this.searchByTopic, options, useCachedVersionIfAvailable, reRequestOnStale).pipe( + getAllSucceededRemoteDataPayload(), + getPaginatedListPayload(), + map((list: CorrectionType[]) => { + return list[0]; + }) + ); + } +} diff --git a/src/app/core/submission/models/correctiontype.model.ts b/src/app/core/submission/models/correctiontype.model.ts new file mode 100644 index 00000000000..9329fa88d8a --- /dev/null +++ b/src/app/core/submission/models/correctiontype.model.ts @@ -0,0 +1,49 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { HALLink } from '../../shared/hal-link.model'; + +@typedObject +/** + * Represents a correction type. It extends the CacheableObject. + * The correction type represents a type of correction that can be applied to a submission. + */ +export class CorrectionType extends CacheableObject { + static type = new ResourceType('correctiontype'); + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + @autoserialize + /** + * The unique identifier for the correction type mode. + */ + id: string; + @autoserialize + /** + * The topic of the correction type mode. + */ + topic: string; + @autoserialize + /** + * The discovery configuration for the correction type mode. + */ + discoveryConfiguration: string; + @autoserialize + /** + * The form used for creating a correction type. + */ + creationForm: string; + @deserialize + /** + * Represents the links associated with the correction type mode. + */ + _links: { + self: HALLink; + }; +} diff --git a/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts b/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts new file mode 100644 index 00000000000..f9441fa7905 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts @@ -0,0 +1,8 @@ +/* + * Object model for the data returned by the REST API to present potential duplicates in a submission section + */ +import { Duplicate } from '../../../shared/object-list/duplicate-data/duplicate.model'; + +export interface WorkspaceitemSectionDuplicatesObject { + potentialDuplicates?: Duplicate[] +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload.model.ts b/src/app/core/submission/models/workspaceitem-section-upload.model.ts index f98e0584ebc..d992567df4c 100644 --- a/src/app/core/submission/models/workspaceitem-section-upload.model.ts +++ b/src/app/core/submission/models/workspaceitem-section-upload.model.ts @@ -4,7 +4,10 @@ import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-up * An interface to represent submission's upload section data. */ export interface WorkspaceitemSectionUploadObject { - + /** + * Primary bitstream flag + */ + primary: string | null; /** * A list of [[WorkspaceitemSectionUploadFileObject]] */ diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts index a3ccd49dace..4c90f3ede86 100644 --- a/src/app/core/submission/models/workspaceitem-sections.model.ts +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -3,8 +3,9 @@ import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.mod import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model'; -import {WorkspaceitemSectionIdentifiersObject} from './workspaceitem-section-identifiers.model'; +import { WorkspaceitemSectionIdentifiersObject } from './workspaceitem-section-identifiers.model'; import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; +import { WorkspaceitemSectionDuplicatesObject } from './workspaceitem-section-duplicates.model'; /** * An interface to represent submission's section object. @@ -25,4 +26,7 @@ export type WorkspaceitemSectionDataType | WorkspaceitemSectionAccessesObject | WorkspaceitemSectionSherpaPoliciesObject | WorkspaceitemSectionIdentifiersObject + | WorkspaceitemSectionDuplicatesObject | string; + + diff --git a/src/app/core/submission/submission-duplicate-data.service.spec.ts b/src/app/core/submission/submission-duplicate-data.service.spec.ts new file mode 100644 index 00000000000..fff4f3a0bc5 --- /dev/null +++ b/src/app/core/submission/submission-duplicate-data.service.spec.ts @@ -0,0 +1,30 @@ +import { SubmissionDuplicateDataService } from './submission-duplicate-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestParam } from '../cache/models/request-param.model'; + +/** + * Basic tests for the submission-duplicate-data.service.ts service + */ +describe('SubmissionDuplicateDataService', () => { + const duplicateDataService = new SubmissionDuplicateDataService(null, null, null, null); + + // Test the findDuplicates method to make sure that a call results in an expected + // call to searchBy, using the 'findByItem' search method + describe('findDuplicates', () => { + beforeEach(() => { + spyOn(duplicateDataService, 'searchBy'); + }); + + it('should call searchBy with the correct arguments', () => { + // Set up expected search parameters and find options + const searchParams = []; + searchParams.push(new RequestParam('uuid', 'test')); + let findListOptions = new FindListOptions(); + findListOptions.searchParams = searchParams; + // Perform test search using uuid 'test' using the findDuplicates method + const result = duplicateDataService.findDuplicates('test', new FindListOptions(), true, true); + // Expect searchBy('findByItem'...) to have been used as SearchData impl with the expected options (uuid=test) + expect(duplicateDataService.searchBy).toHaveBeenCalledWith('findByItem', findListOptions, true, true); + }); + }); +}); diff --git a/src/app/core/submission/submission-duplicate-data.service.ts b/src/app/core/submission/submission-duplicate-data.service.ts new file mode 100644 index 00000000000..7e0e97e80b8 --- /dev/null +++ b/src/app/core/submission/submission-duplicate-data.service.ts @@ -0,0 +1,139 @@ +/* eslint-disable max-classes-per-file */ +import { Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { ResponseParsingService } from '../data/parsing.service'; +import { RemoteData } from '../data/remote-data'; +import { GetRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { SearchResponseParsingService } from '../data/search-response-parsing.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestRequest } from '../data/rest-request.model'; +import { BaseDataService } from '../data/base/base-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { Duplicate } from '../../shared/object-list/duplicate-data/duplicate.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { DUPLICATE } from '../../shared/object-list/duplicate-data/duplicate.resource-type'; +import { dataService } from '../data/base/data-service.decorator'; + + +/** + * Service that handles search requests for potential duplicate items. + * This uses the /api/submission/duplicates endpoint to look for other archived or in-progress items (if user + * has READ permission) that match the item (for the given uuid). + * Matching is configured in the backend in dspace/config/modulesduplicate-detection.cfg + * The returned results are small preview 'stubs' of items, and displayed in either a submission section + * or the workflow pooled/claimed task page. + * + */ +@Injectable() +@dataService(DUPLICATE) +export class SubmissionDuplicateDataService extends BaseDataService implements SearchData { + + /** + * The ResponseParsingService constructor name + */ + private parser: GenericConstructor = SearchResponseParsingService; + + /** + * The RestRequest constructor name + */ + private request: GenericConstructor = GetRequest; + + /** + * SearchData interface to implement + * @private + */ + private searchData: SearchData; + + /** + * Subscription to unsubscribe from + */ + private sub; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('duplicates', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Implement the searchBy method to return paginated lists of Duplicate resources + * + * @param searchMethod the search method name + * @param options find list options + * @param useCachedVersionIfAvailable whether to use cached version if available + * @param reRequestOnStale whether to rerequest results on stale + * @param linksToFollow links to follow in results + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Helper method to get the duplicates endpoint + * @protected + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Method to set service options + * @param {GenericConstructor} parser The ResponseParsingService constructor name + * @param {boolean} request The RestRequest constructor name + */ + setServiceOptions(parser: GenericConstructor, request: GenericConstructor) { + if (parser) { + this.parser = parser; + } + if (request) { + this.request = request; + } + } + + /** + * Find duplicates for a given item UUID. Locates and returns results from the /api/submission/duplicates/search/findByItem + * SearchRestMethod, which is why this implements SearchData and searchBy + * + * @param uuid the item UUID + * @param options any find list options e.g. paging + * @param useCachedVersionIfAvailable whether to use cached version if available + * @param reRequestOnStale whether to rerequest results on stale + * @param linksToFollow links to follow in results + */ + public findDuplicates(uuid: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchParams = [new RequestParam('uuid', uuid)]; + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + + // Return actual search/findByItem results + return this.searchBy('findByItem', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (this.sub !== undefined) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts index 4b35871418f..eecf86a2118 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts @@ -7,8 +7,16 @@ */ import { VocabularyDataService } from './vocabulary.data.service'; import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils'; describe('VocabularyDataService', () => { + let service: VocabularyDataService; + service = initTestService(); + let restEndpointURL = 'https://rest.api/server/api/submission/vocabularies'; + let vocabularyByMetadataAndCollectionEndpoint = `${restEndpointURL}/search/byMetadataAndCollection?metadata=dc.contributor.author&collection=1234-1234`; + function initTestService() { return new VocabularyDataService(null, null, null, null); } @@ -17,4 +25,18 @@ describe('VocabularyDataService', () => { const initService = () => new VocabularyDataService(null, null, null, null); testFindAllDataImplementation(initService); }); + + describe('getVocabularyByMetadataAndCollection', () => { + it('search vocabulary by metadata and collection calls expected methods', () => { + spyOn((service as any).searchData, 'getSearchByHref').and.returnValue(vocabularyByMetadataAndCollectionEndpoint); + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + service.getVocabularyByMetadataAndCollection('dc.contributor.author', '1234-1234'); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('metadata', encodeURIComponent('dc.contributor.author'))), + Object.assign(new RequestParam('collection', encodeURIComponent('1234-1234')))] + }); + expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('byMetadataAndCollection', options); + expect(service.findByHref).toHaveBeenCalledWith(vocabularyByMetadataAndCollectionEndpoint, true, true); + }); + }); }); diff --git a/src/app/core/submission/vocabularies/vocabulary.data.service.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.ts index a67b67ced70..9215990decf 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.ts @@ -20,6 +20,8 @@ import { PaginatedList } from '../../data/paginated-list.model'; import { Injectable } from '@angular/core'; import { VOCABULARY } from './models/vocabularies.resource-type'; import { dataService } from '../../data/base/data-service.decorator'; +import { SearchDataImpl } from '../../data/base/search-data'; +import { RequestParam } from '../../cache/models/request-param.model'; /** * Data service to retrieve vocabularies from the REST server. @@ -27,7 +29,10 @@ import { dataService } from '../../data/base/data-service.decorator'; @Injectable() @dataService(VOCABULARY) export class VocabularyDataService extends IdentifiableDataService implements FindAllData { + protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection'; + private findAllData: FindAllData; + private searchData: SearchDataImpl; constructor( protected requestService: RequestService, @@ -38,6 +43,7 @@ export class VocabularyDataService extends IdentifiableDataService i super('vocabularies', 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); } /** @@ -57,4 +63,23 @@ export class VocabularyDataService extends IdentifiableDataService i public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>) + * @param metadataField metadata field to search + * @param collectionUUID collection UUID where is configured the vocabulary + * @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 + */ + public getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('metadata', encodeURIComponent(metadataField)), + new RequestParam('collection', encodeURIComponent(collectionUUID))]; + const href$ = this.searchData.getSearchByHref(this.searchByMetadataAndCollectionPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index faa58235203..38824b3fac0 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -25,6 +25,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RequestEntry } from '../../data/request-entry.model'; import { VocabularyDataService } from './vocabulary.data.service'; import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; describe('VocabularyService', () => { let scheduler: TestScheduler; @@ -205,6 +206,7 @@ describe('VocabularyService', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); + objectCache = new ObjectCacheServiceStub() as ObjectCacheService; return new VocabularyService( requestService, @@ -253,7 +255,9 @@ describe('VocabularyService', () => { spyOn((service as any).vocabularyDataService, 'findById').and.callThrough(); spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough(); spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough(); spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); + spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); }); afterEach(() => { @@ -310,6 +314,23 @@ describe('VocabularyService', () => { expect(result).toBeObservable(expected); }); }); + + describe('getVocabularyByMetadataAndCollection', () => { + it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => { + scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true); + }); + + it('should return a RemoteData for the object with the given metadata and collection', () => { + const result = service.getVocabularyByMetadataAndCollection(metadata, collectionUUID); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); }); describe('vocabulary entries', () => { diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 1ff5b30ee08..2dd2cc3792f 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -87,6 +87,23 @@ export class VocabularyService { return this.vocabularyDataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Return the controlled vocabulary configured for the specified metadata and collection if any + * @param metadataField metadata field to search + * @param collectionUUID collection UUID where is configured the vocabulary + * @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 vocabulary object + */ + getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} * diff --git a/src/app/core/submission/workflowitem-data.service.spec.ts b/src/app/core/submission/workflowitem-data.service.spec.ts index 3f6ec54fdad..64ffbe57180 100644 --- a/src/app/core/submission/workflowitem-data.service.spec.ts +++ b/src/app/core/submission/workflowitem-data.service.spec.ts @@ -126,7 +126,7 @@ describe('WorkflowItemDataService test', () => { }); describe('findByItem', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); scheduler.flush(); diff --git a/src/app/core/submission/workspaceitem-data.service.spec.ts b/src/app/core/submission/workspaceitem-data.service.spec.ts index e766a6a039c..25a849baa28 100644 --- a/src/app/core/submission/workspaceitem-data.service.spec.ts +++ b/src/app/core/submission/workspaceitem-data.service.spec.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; @@ -8,7 +8,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { PageInfo } from '../shared/page-info.model'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HrefOnlyDataService } from '../data/href-only-data.service'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { WorkspaceitemDataService } from './workspaceitem-data.service'; @@ -21,6 +21,11 @@ import { RequestEntry } from '../data/request-entry.model'; import { CoreState } from '../core-state.model'; import { testSearchDataImplementation } from '../data/base/search-data.spec'; import { testDeleteDataImplementation } from '../data/base/delete-data.spec'; +import { SearchData } from '../data/base/search-data'; +import { DeleteData } from '../data/base/delete-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PostRequest } from '../data/request.models'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; describe('WorkspaceitemDataService test', () => { let scheduler: TestScheduler; @@ -68,15 +73,12 @@ describe('WorkspaceitemDataService test', () => { const wsiRD = createSuccessfulRemoteDataObject(wsi); const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; - const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; - const searchRequestURL$ = observableOf(searchRequestURL); const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; objectCache = {} as ObjectCacheService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; - const comparator = {} as any; const comparatorEntry = {} as any; const store = {} as Store; const pageInfo = new PageInfo(); @@ -84,18 +86,23 @@ describe('WorkspaceitemDataService test', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); return new WorkspaceitemDataService( + comparatorEntry, + halService, + http, + notificationsService, requestService, rdbService, objectCache, - halService, - notificationsService, + store ); } describe('composition', () => { - const initService = () => new WorkspaceitemDataService(null, null, null, null, null); - testSearchDataImplementation(initService); - testDeleteDataImplementation(initService); + const initSearchService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null) as unknown as SearchData; + const initDeleteService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null) as unknown as DeleteData; + + testSearchDataImplementation(initSearchService); + testDeleteDataImplementation(initDeleteService); }); describe('', () => { @@ -104,7 +111,7 @@ describe('WorkspaceitemDataService test', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: endpointURL }) + getEndpoint: observableOf(endpointURL) }); responseCacheEntry = new RequestEntry(); responseCacheEntry.request = { href: 'https://rest.api/' } as any; @@ -120,13 +127,13 @@ describe('WorkspaceitemDataService test', () => { rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { a: wsiRD - }) + }), + buildFromRequestUUID: createSuccessfulRemoteDataObject$({}) }); service = initTestService(); spyOn((service as any), 'findByHref').and.callThrough(); - spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$); }); afterEach(() => { @@ -134,11 +141,11 @@ describe('WorkspaceitemDataService test', () => { }); describe('findByItem', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); scheduler.flush(); - - expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true); + const searchUrl = service.getIDHref('item', [new RequestParam('uuid', encodeURIComponent('1234-1234'))]); + expect((service as any).findByHref).toHaveBeenCalledWith(searchUrl, true, true); }); it('should return a RemoteData for the search', () => { @@ -150,6 +157,19 @@ describe('WorkspaceitemDataService test', () => { }); }); - }); + describe('importExternalSourceEntry', () => { + it('should send a POST request containing the provided item request', (done) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + service.importExternalSourceEntry('externalHref', 'testId').subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestUUID, `${endpointURL}?owningCollection=testId`, 'externalHref', options)); + done(); + }); + }); + }); + }); }); diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index f285fb6eca5..38810d8fd6f 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -1,44 +1,70 @@ import { Injectable } from '@angular/core'; +import {HttpClient, HttpHeaders} from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { WorkspaceItem } from './models/workspaceitem.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RequestParam } from '../cache/models/request-param.model'; +import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import {HttpOptions} from '../dspace-rest/dspace-rest.service'; +import {find, map} from 'rxjs/operators'; +import {PostRequest} from '../data/request.models'; +import {hasValue} from '../../shared/empty.util'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { NoContent } from '../shared/NoContent.model'; +import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; import { SearchData, SearchDataImpl } from '../data/base/search-data'; import { PaginatedList } from '../data/paginated-list.model'; -import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; -import { NoContent } from '../shared/NoContent.model'; -import { dataService } from '../data/base/data-service.decorator'; /** * A service that provides methods to make REST requests with workspaceitems endpoint. */ @Injectable() @dataService(WorkspaceItem.type) -export class WorkspaceitemDataService extends IdentifiableDataService implements SearchData, DeleteData { +export class WorkspaceitemDataService extends IdentifiableDataService implements DeleteData, SearchData{ + protected linkPath = 'workspaceitems'; protected searchByItemLinkPath = 'item'; - - private searchData: SearchDataImpl; - private deleteData: DeleteDataImpl; + private deleteData: DeleteData; + private searchData: SearchData; constructor( + protected comparator: DSOChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - ) { + protected store: Store) { super('workspaceitems', requestService, rdbService, objectCache, halService); - - 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.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); } /** @@ -55,21 +81,33 @@ export class WorkspaceitemDataService extends IdentifiableDataService[]): Observable> { const findListOptions = new FindListOptions(); findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))]; - const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); + const href$ = this.getIDHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** - * Create the HREF for a specific object's search method with given options object - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * Import an external source entry into a collection + * @param externalSourceEntryHref + * @param collectionId */ - public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow): Observable { - return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + public importExternalSourceEntry(externalSourceEntryHref: string, collectionId: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.linkPath).pipe(map((href) => `${href}?owningCollection=${collectionId}`)); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, externalSourceEntryHref, options); + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); } /** @@ -86,33 +124,7 @@ export class WorkspaceitemDataService extends IdentifiableDataService>} * Return an observable that emits response from the server */ - public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } - - /** - * Delete an existing object on the server - * @param objectId The id of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - */ - public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { - return this.deleteData.delete(objectId, copyVirtualMetadata); - } - - /** - * Delete an existing object on the server - * @param href The self link of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - * Only emits once all request related to the DSO has been invalidated. - */ - public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { - return this.deleteData.deleteByHref(href, copyVirtualMetadata); - } - } diff --git a/src/app/core/supervision-order/supervision-order-data.service.spec.ts b/src/app/core/supervision-order/supervision-order-data.service.spec.ts index b12817fa1af..5e25a7c99ab 100644 --- a/src/app/core/supervision-order/supervision-order-data.service.spec.ts +++ b/src/app/core/supervision-order/supervision-order-data.service.spec.ts @@ -17,13 +17,14 @@ import { RestResponse } from '../cache/response.models'; import { RequestEntry } from '../data/request-entry.model'; import { FindListOptions } from '../data/find-list-options.model'; import { GroupDataService } from '../eperson/group-data.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('SupervisionOrderService', () => { let scheduler: TestScheduler; let service: SupervisionOrderDataService; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; let groupService: GroupDataService; @@ -134,7 +135,7 @@ describe('SupervisionOrderService', () => { service = new SupervisionOrderDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator, diff --git a/src/app/home-page/home-page.module.ts b/src/app/home-page/home-page.module.ts index 1681abd8058..35784a31063 100644 --- a/src/app/home-page/home-page.module.ts +++ b/src/app/home-page/home-page.module.ts @@ -3,7 +3,6 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { HomeNewsComponent } from './home-news/home-news.component'; import { HomePageRoutingModule } from './home-page-routing.module'; - import { HomePageComponent } from './home-page.component'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; import { StatisticsModule } from '../statistics/statistics.module'; @@ -13,6 +12,7 @@ import { RecentItemListComponent } from './recent-item-list/recent-item-list.com import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; import { ThemedTopLevelCommunityListComponent } from './top-level-community-list/themed-top-level-community-list.component'; +import { NotificationsModule } from '../notifications/notifications.module'; const DECLARATIONS = [ HomePageComponent, @@ -31,7 +31,8 @@ const DECLARATIONS = [ JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), HomePageRoutingModule, - StatisticsModule.forRoot() + StatisticsModule.forRoot(), + NotificationsModule ], declarations: [ ...DECLARATIONS, diff --git a/src/app/info/info-routing.module.ts b/src/app/info/info-routing.module.ts index 4c497461e71..c91c534fe44 100644 --- a/src/app/info/info-routing.module.ts +++ b/src/app/info/info-routing.module.ts @@ -7,6 +7,9 @@ import { ThemedPrivacyComponent } from './privacy/themed-privacy.component'; import { ThemedFeedbackComponent } from './feedback/themed-feedback.component'; import { FeedbackGuard } from '../core/feedback/feedback.guard'; import { environment } from '../../environments/environment'; +import { COAR_NOTIFY_SUPPORT } from '../app-routing-paths'; +import { NotifyInfoComponent } from '../core/coar-notify/notify-info/notify-info.component'; +import { NotifyInfoGuard } from '../core/coar-notify/notify-info/notify-info.guard'; const imports = [ @@ -18,11 +21,20 @@ const imports = [ data: { title: 'info.feedback.title', breadcrumbKey: 'info.feedback' }, canActivate: [FeedbackGuard] } + ]), + RouterModule.forChild([ + { + path: COAR_NOTIFY_SUPPORT, + component: NotifyInfoComponent, + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { title: 'info.coar-notify-support.title', breadcrumbKey: 'info.coar-notify' }, + canActivate: [NotifyInfoGuard] + } ]) ]; - if (environment.info.enableEndUserAgreement) { - imports.push( +if (environment.info.enableEndUserAgreement) { + imports.push( RouterModule.forChild([ { path: END_USER_AGREEMENT_PATH, @@ -31,9 +43,9 @@ const imports = [ data: { title: 'info.end-user-agreement.title', breadcrumbKey: 'info.end-user-agreement' } } ])); - } - if (environment.info.enablePrivacyStatement) { - imports.push( +} +if (environment.info.enablePrivacyStatement) { + imports.push( RouterModule.forChild([ { path: PRIVACY_PATH, @@ -42,7 +54,7 @@ const imports = [ data: { title: 'info.privacy.title', breadcrumbKey: 'info.privacy' } } ])); - } +} @NgModule({ imports: [ diff --git a/src/app/info/info.module.ts b/src/app/info/info.module.ts index ccc4af0a7dd..0cadbf4bfac 100644 --- a/src/app/info/info.module.ts +++ b/src/app/info/info.module.ts @@ -13,6 +13,7 @@ import { FeedbackFormComponent } from './feedback/feedback-form/feedback-form.co import { ThemedFeedbackFormComponent } from './feedback/feedback-form/themed-feedback-form.component'; import { ThemedFeedbackComponent } from './feedback/themed-feedback.component'; import { FeedbackGuard } from '../core/feedback/feedback.guard'; +import { NotifyInfoComponent } from '../core/coar-notify/notify-info/notify-info.component'; const DECLARATIONS = [ @@ -25,7 +26,8 @@ const DECLARATIONS = [ FeedbackComponent, FeedbackFormComponent, ThemedFeedbackFormComponent, - ThemedFeedbackComponent + ThemedFeedbackComponent, + NotifyInfoComponent ]; @NgModule({ diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 9fc078c2cd7..c09c3177e3d 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -23,6 +23,7 @@ import { RemoteData } from '../../core/data/remote-data'; import { ServerResponseService } from '../../core/services/server-response.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { LinkHeadService } from '../../core/services/link-head.service'; +import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -61,6 +62,7 @@ describe('FullItemPageComponent', () => { let serverResponseService: jasmine.SpyObj; let signpostingDataService: jasmine.SpyObj; let linkHeadService: jasmine.SpyObj; + let notifyInfoService: jasmine.SpyObj; const mocklink = { href: 'http://test.org', @@ -105,6 +107,12 @@ describe('FullItemPageComponent', () => { removeTag: jasmine.createSpy('removeTag'), }); + notifyInfoService = jasmine.createSpyObj('NotifyInfoService', { + isCoarConfigEnabled: observableOf(true), + getCoarLdnLocalInboxUrls: observableOf(['http://test.org']), + getInboxRelationLink: observableOf('http://test.org'), + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -122,6 +130,7 @@ describe('FullItemPageComponent', () => { { provide: ServerResponseService, useValue: serverResponseService }, { provide: SignpostingDataService, useValue: signpostingDataService }, { provide: LinkHeadService, useValue: linkHeadService }, + { provide: NotifyInfoService, useValue: notifyInfoService }, { provide: PLATFORM_ID, useValue: 'server' } ], schemas: [NO_ERRORS_SCHEMA] @@ -178,7 +187,7 @@ describe('FullItemPageComponent', () => { it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); - expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(3); }); }); describe('when the item is withdrawn and the user is not an admin', () => { @@ -207,7 +216,7 @@ describe('FullItemPageComponent', () => { it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); - expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(3); }); }); @@ -224,7 +233,7 @@ describe('FullItemPageComponent', () => { it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); - expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(3); }); }); }); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 31dd2c5fc28..09238c30ab4 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -19,6 +19,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ import { ServerResponseService } from '../../core/services/server-response.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { LinkHeadService } from '../../core/services/link-head.service'; +import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; /** * This component renders a full item page. @@ -55,9 +56,10 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, protected responseService: ServerResponseService, protected signpostingDataService: SignpostingDataService, protected linkHeadService: LinkHeadService, + protected notifyInfoService: NotifyInfoService, @Inject(PLATFORM_ID) protected platformId: string, ) { - super(route, router, items, authService, authorizationService, responseService, signpostingDataService, linkHeadService, platformId); + super(route, router, items, authService, authorizationService, responseService, signpostingDataService, linkHeadService, notifyInfoService, platformId); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index a8d41d15352..7fd7b3b6236 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -60,6 +60,9 @@ import { ThemedItemAlertsComponent } from './alerts/themed-item-alerts.component import { ThemedFullFileSectionComponent } from './full/field-components/file-section/themed-full-file-section.component'; +import { QaEventNotificationComponent } from './simple/qa-event-notification/qa-event-notification.component'; +import { NotifyRequestsStatusComponent } from './simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component'; +import { RequestStatusAlertBoxComponent } from './simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -103,6 +106,9 @@ const DECLARATIONS = [ ItemAlertsComponent, ThemedItemAlertsComponent, BitstreamRequestACopyPageComponent, + QaEventNotificationComponent, + NotifyRequestsStatusComponent, + RequestStatusAlertBoxComponent ]; @NgModule({ diff --git a/src/app/item-page/item-shared.module.ts b/src/app/item-page/item-shared.module.ts index 9c2bbba6194..9a7477e6aea 100644 --- a/src/app/item-page/item-shared.module.ts +++ b/src/app/item-page/item-shared.module.ts @@ -16,10 +16,14 @@ import { RelatedItemsComponent } from './simple/related-items/related-items-comp import { ThemedMetadataRepresentationListComponent } from './simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { + ItemWithdrawnReinstateModalComponent +} from '../shared/correction-suggestion/withdrawn-reinstate-modal.component'; const ENTRY_COMPONENTS = [ ItemVersionsDeleteModalComponent, ItemVersionsSummaryModalComponent, + ItemWithdrawnReinstateModalComponent ]; diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index cc9983bb354..dc8ed87a86a 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -2,6 +2,8 @@
+ + diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index b3202108f43..d4a83fbf852 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -26,6 +26,7 @@ import { ServerResponseService } from '../../core/services/server-response.servi import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; import { SignpostingLink } from '../../core/data/signposting-links.model'; +import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -62,6 +63,7 @@ describe('ItemPageComponent', () => { let serverResponseService: jasmine.SpyObj; let signpostingDataService: jasmine.SpyObj; let linkHeadService: jasmine.SpyObj; + let notifyInfoService: jasmine.SpyObj; const mockMetadataService = { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ @@ -73,6 +75,8 @@ describe('ItemPageComponent', () => { data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }) }); + const getCoarLdnLocalInboxUrls = ['http://InboxUrls.org', 'http://InboxUrls2.org']; + beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), @@ -94,6 +98,12 @@ describe('ItemPageComponent', () => { removeTag: jasmine.createSpy('removeTag'), }); + notifyInfoService = jasmine.createSpyObj('NotifyInfoService', { + getInboxRelationLink: 'http://www.w3.org/ns/ldp#inbox', + isCoarConfigEnabled: observableOf(true), + getCoarLdnLocalInboxUrls: observableOf(getCoarLdnLocalInboxUrls), + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -112,6 +122,7 @@ describe('ItemPageComponent', () => { { provide: ServerResponseService, useValue: serverResponseService }, { provide: SignpostingDataService, useValue: signpostingDataService }, { provide: LinkHeadService, useValue: linkHeadService }, + { provide: NotifyInfoService, useValue: notifyInfoService}, { provide: PLATFORM_ID, useValue: 'server' }, ], @@ -166,7 +177,7 @@ describe('ItemPageComponent', () => { it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); - expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(4); }); @@ -175,7 +186,7 @@ describe('ItemPageComponent', () => { expect(comp.signpostingLinks).toEqual([mocklink, mocklink2]); // Check if linkHeadService.addTag() was called with the correct arguments - expect(linkHeadService.addTag).toHaveBeenCalledTimes(mockSignpostingLinks.length); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(mockSignpostingLinks.length + getCoarLdnLocalInboxUrls.length); let expected: LinkDefinition = mockSignpostingLinks[0] as LinkDefinition; expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); expected = { @@ -186,8 +197,7 @@ describe('ItemPageComponent', () => { }); it('should set Link header on the server', () => { - - expect(serverResponseService.setHeader).toHaveBeenCalledWith('Link', ' ; rel="rel1" ; type="type1" , ; rel="rel2" '); + expect(serverResponseService.setHeader).toHaveBeenCalledWith('Link', ' ; rel="rel1" ; type="type1" , ; rel="rel2" , ; rel="http://www.w3.org/ns/ldp#inbox", ; rel="http://www.w3.org/ns/ldp#inbox"'); }); }); @@ -217,7 +227,7 @@ describe('ItemPageComponent', () => { it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); - expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(4); }); }); @@ -234,7 +244,7 @@ describe('ItemPageComponent', () => { it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); - expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(4); }); }); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index b9be6bebfb6..a057e99715a 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM import { ActivatedRoute, Router } from '@angular/router'; import { isPlatformServer } from '@angular/common'; -import { Observable } from 'rxjs'; -import { map, take } from 'rxjs/operators'; +import { Observable, combineLatest } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -21,6 +21,7 @@ import { SignpostingDataService } from '../../core/data/signposting-data.service import { SignpostingLink } from '../../core/data/signposting-links.model'; import { isNotEmpty } from '../../shared/empty.util'; import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; +import { NotifyInfoService } from 'src/app/core/coar-notify/notify-info/notify-info.service'; /** * This component renders a simple item page. @@ -32,7 +33,7 @@ import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.s styleUrls: ['./item-page.component.scss'], templateUrl: './item-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut] + animations: [fadeInOut], }) export class ItemPageComponent implements OnInit, OnDestroy { @@ -68,6 +69,13 @@ export class ItemPageComponent implements OnInit, OnDestroy { */ signpostingLinks: SignpostingLink[] = []; + /** + * An array of LinkDefinition objects representing inbox links for the item page. + */ + inboxTags: LinkDefinition[] = []; + + coarRestApiUrls: string[] = []; + constructor( protected route: ActivatedRoute, protected router: Router, @@ -77,6 +85,7 @@ export class ItemPageComponent implements OnInit, OnDestroy { protected responseService: ServerResponseService, protected signpostingDataService: SignpostingDataService, protected linkHeadService: LinkHeadService, + protected notifyInfoService: NotifyInfoService, @Inject(PLATFORM_ID) protected platformId: string ) { this.initPageLinks(); @@ -106,7 +115,8 @@ export class ItemPageComponent implements OnInit, OnDestroy { */ private initPageLinks(): void { this.route.params.subscribe(params => { - this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => { + combineLatest([this.signpostingDataService.getLinks(params.id).pipe(take(1)), this.getCoarLdnLocalInboxUrls()]) + .subscribe(([signpostingLinks, coarRestApiUrls]) => { let links = ''; this.signpostingLinks = signpostingLinks; @@ -124,6 +134,11 @@ export class ItemPageComponent implements OnInit, OnDestroy { this.linkHeadService.addTag(tag); }); + if (coarRestApiUrls.length > 0) { + let inboxLinks = this.initPageInboxLinks(coarRestApiUrls); + links = links + (isNotEmpty(links) ? ', ' : '') + inboxLinks; + } + if (isPlatformServer(this.platformId)) { this.responseService.setHeader('Link', links); } @@ -131,9 +146,49 @@ export class ItemPageComponent implements OnInit, OnDestroy { }); } + /** + * Sets the COAR LDN local inbox URL if COAR configuration is enabled. + * If the COAR LDN local inbox URL is retrieved successfully, initializes the page inbox links. + */ + private getCoarLdnLocalInboxUrls(): Observable { + return this.notifyInfoService.isCoarConfigEnabled().pipe( + switchMap((coarLdnEnabled: boolean) => { + if (coarLdnEnabled) { + return this.notifyInfoService.getCoarLdnLocalInboxUrls(); + } + }) + ); + } + + /** + * Initializes the page inbox links. + * @param coarRestApiUrls - An array of COAR REST API URLs. + */ + private initPageInboxLinks(coarRestApiUrls: string[]): string { + const rel = this.notifyInfoService.getInboxRelationLink(); + let links = ''; + + coarRestApiUrls.forEach((coarRestApiUrl: string) => { + // Add link to head + let tag: LinkDefinition = { + href: coarRestApiUrl, + rel: rel + }; + this.inboxTags.push(tag); + this.linkHeadService.addTag(tag); + + links = links + (isNotEmpty(links) ? ', ' : '') + `<${coarRestApiUrl}> ; rel="${rel}"`; + }); + + return links; + } + ngOnDestroy(): void { this.signpostingLinks.forEach((link: SignpostingLink) => { this.linkHeadService.removeTag(`href='${link.href}'`); }); + this.inboxTags.forEach((link: LinkDefinition) => { + this.linkHeadService.removeTag(`href='${link.href}'`); + }); } } diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index 3749f639644..ca93421f3de 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -84,6 +84,22 @@ [label]="'item.page.uri'"> + + + + + + + +
{{"item.page.link.full" | translate}} diff --git a/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.html b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.html new file mode 100644 index 00000000000..c68255f2eda --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.spec.ts b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.spec.ts new file mode 100644 index 00000000000..0ec4febc0be --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.spec.ts @@ -0,0 +1,88 @@ +import { ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing'; +import { NotifyRequestsStatusComponent } from './notify-requests-status.component'; +import { NotifyRequestsStatusDataService } from 'src/app/core/data/notify-services-status-data.service'; +import { NotifyRequestsStatus } from '../notify-requests-status.model'; +import { RequestStatusEnum } from '../notify-status.enum'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('NotifyRequestsStatusComponent', () => { + let component: NotifyRequestsStatusComponent; + let fixture: ComponentFixture; + let notifyInfoServiceSpy; + + const mock: NotifyRequestsStatus = Object.assign(new NotifyRequestsStatus(), { + notifyStatus: [], + itemuuid: 'testUuid' + }); + + beforeEach(() => { + notifyInfoServiceSpy = { + getNotifyRequestsStatus:() => createSuccessfulRemoteDataObject$(mock) + }; + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [NotifyRequestsStatusComponent], + providers: [ + { provide: NotifyRequestsStatusDataService, useValue: notifyInfoServiceSpy } + ] + }); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotifyRequestsStatusComponent); + component = fixture.componentInstance; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should fetch data from the service on initialization', fakeAsync(() => { + const mockData: NotifyRequestsStatus = Object.assign(new NotifyRequestsStatus(), { + notifyStatus: [], + itemuuid: 'testUuid' + }); + component.itemUuid = mockData.itemuuid; + spyOn(notifyInfoServiceSpy, 'getNotifyRequestsStatus').and.callThrough(); + component.ngOnInit(); + fixture.detectChanges(); + tick(); + + expect(notifyInfoServiceSpy.getNotifyRequestsStatus).toHaveBeenCalledWith('testUuid'); + component.requestMap$.subscribe((map) => { + expect(map.size).toBe(0); + }); + })); + + it('should group data by status', () => { + const mockData: NotifyRequestsStatus = Object.assign(new NotifyRequestsStatus(), { + notifyStatus: [ + { + serviceName: 'test1', + serviceUrl: 'test', + status: RequestStatusEnum.ACCEPTED, + }, + { + serviceName: 'test2', + serviceUrl: 'test', + status: RequestStatusEnum.REJECTED, + }, + { + serviceName: 'test3', + serviceUrl: 'test', + status: RequestStatusEnum.ACCEPTED, + }, + ], + itemUuid: 'testUuid' + }); + spyOn(notifyInfoServiceSpy, 'getNotifyRequestsStatus').and.returnValue(createSuccessfulRemoteDataObject$(mockData)); + fixture.detectChanges(); + (component as any).groupDataByStatus(mockData); + component.requestMap$.subscribe((map) => { + expect(map.size).toBe(2); + expect(map.get(RequestStatusEnum.ACCEPTED)?.length).toBe(2); + expect(map.get(RequestStatusEnum.REJECTED)?.length).toBe(1); + }); + }); +}); diff --git a/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts new file mode 100644 index 00000000000..e62534f4d57 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts @@ -0,0 +1,77 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, +} from '@angular/core'; +import { Observable, filter, map } from 'rxjs'; +import { + NotifyRequestsStatus, + NotifyStatuses, +} from '../notify-requests-status.model'; +import { NotifyRequestsStatusDataService } from '../../../../core/data/notify-services-status-data.service'; +import { RequestStatusEnum } from '../notify-status.enum'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; +@Component({ + selector: 'ds-notify-requests-status', + templateUrl: './notify-requests-status.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) + +/** + * Component to show an alert box for each update in th Notify feature (e.g. COAR updates) + */ + +export class NotifyRequestsStatusComponent implements OnInit { + /** + * The UUID of the item. + */ + @Input() itemUuid: string; + + /** + * Observable representing the request map. + * The map contains request status enums as keys and arrays of notify statuses as values. + */ + requestMap$: Observable>; + + constructor(private notifyInfoService: NotifyRequestsStatusDataService) { } + + ngOnInit(): void { + this.requestMap$ = this.notifyInfoService + .getNotifyRequestsStatus(this.itemUuid) + .pipe( + getFirstCompletedRemoteData(), + filter((data) => hasValue(data)), + getRemoteDataPayload(), + filter((data: NotifyRequestsStatus) => hasValue(data)), + map((data: NotifyRequestsStatus) => { + return this.groupDataByStatus(data); + }) + ); + } + + /** + * Groups the notify requests status data by status. + * @param notifyRequestsStatus The notify requests status data. + */ + private groupDataByStatus(notifyRequestsStatus: NotifyRequestsStatus) { + const statusMap: Map = new Map(); + notifyRequestsStatus.notifyStatus?.forEach( + (notifyStatus: NotifyStatuses) => { + const status = notifyStatus.status; + + if (!statusMap.has(status)) { + statusMap.set(status, []); + } + + statusMap.get(status)?.push(notifyStatus); + } + ); + + return statusMap; + } +} diff --git a/src/app/item-page/simple/notify-requests-status/notify-requests-status.model.ts b/src/app/item-page/simple/notify-requests-status/notify-requests-status.model.ts new file mode 100644 index 00000000000..95d8b0ba054 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/notify-requests-status.model.ts @@ -0,0 +1,72 @@ +// eslint-disable-next-line max-classes-per-file +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { CacheableObject } from '../../../core/cache/cacheable-object.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { NOTIFYREQUEST } from './notify-requests-status.resource-type'; +import { HALLink } from '../../../core/shared/hal-link.model'; +import { RequestStatusEnum } from './notify-status.enum'; + +/** + * Represents the status of notify requests for an item. + */ +@typedObject +@inheritSerialization(CacheableObject) +export class NotifyRequestsStatus implements CacheableObject { + static type = NOTIFYREQUEST; + + /** + * The object type. + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The notify statuses. + */ + @autoserialize + notifyStatus: NotifyStatuses[]; + + /** + * The UUID of the item. + */ + @autoserialize + itemuuid: string; + + /** + * The links associated with the notify requests status. + */ + @deserialize + _links: { + self: HALLink; + [k: string]: HALLink | HALLink[]; + }; +} + +/** + * Represents the status of a notification request. + */ +export class NotifyStatuses { + /** + * The name of the service. + */ + serviceName: string; + + /** + * The URL of the service. + */ + serviceUrl: string; + + /** + * The status of the notification request. + */ + status: RequestStatusEnum; + /** + * Type of request. + */ + offerType: string; +} + + diff --git a/src/app/item-page/simple/notify-requests-status/notify-requests-status.resource-type.ts b/src/app/item-page/simple/notify-requests-status/notify-requests-status.resource-type.ts new file mode 100644 index 00000000000..53b5545fd47 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/notify-requests-status.resource-type.ts @@ -0,0 +1,8 @@ +import {ResourceType} from '../../../core/shared/resource-type'; +/** + * The resource type for the root endpoint + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const NOTIFYREQUEST = new ResourceType('notifyrequests'); diff --git a/src/app/item-page/simple/notify-requests-status/notify-status.enum.ts b/src/app/item-page/simple/notify-requests-status/notify-status.enum.ts new file mode 100644 index 00000000000..e44c6141302 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/notify-status.enum.ts @@ -0,0 +1,5 @@ +export enum RequestStatusEnum { + ACCEPTED = 'ACCEPTED', + REJECTED = 'REJECTED', + REQUESTED = 'REQUESTED', +} diff --git a/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.html b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.html new file mode 100644 index 00000000000..28022f48372 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.html @@ -0,0 +1,33 @@ + +
+ + + +
+ +
+
+
+
+
+
+
diff --git a/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.scss b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.scss new file mode 100644 index 00000000000..f852bb8454c --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.scss @@ -0,0 +1,7 @@ +.source-logo { + max-height: var(--ds-header-logo-height); +} + +.sections-gap { + gap: 1rem; +} diff --git a/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.spec.ts b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.spec.ts new file mode 100644 index 00000000000..b379af657f8 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { RequestStatusAlertBoxComponent } from './request-status-alert-box.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { RequestStatusEnum } from '../notify-status.enum'; + +describe('RequestStatusAlertBoxComponent', () => { + let component: RequestStatusAlertBoxComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + + const mockData = [ + { + serviceName: 'test', + serviceUrl: 'test', + status: RequestStatusEnum.ACCEPTED, + offerType: 'test' + }, + { + serviceName: 'test1', + serviceUrl: 'test', + status: RequestStatusEnum.REJECTED, + offerType: 'test' + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [RequestStatusAlertBoxComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RequestStatusAlertBoxComponent); + component = fixture.componentInstance; + component.data = mockData; + component.displayOptions = { + alertType: 'alert-danger', + text: 'request-status-alert-box.rejected', + }; + componentAsAny = component; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should display the alert box when data is available', fakeAsync(() => { + const alertBoxElement = fixture.nativeElement.querySelector('.alert'); + expect(alertBoxElement).toBeTruthy(); + })); +}); diff --git a/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.ts b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.ts new file mode 100644 index 00000000000..355980836a7 --- /dev/null +++ b/src/app/item-page/simple/notify-requests-status/request-status-alert-box/request-status-alert-box.component.ts @@ -0,0 +1,82 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + type OnInit, +} from '@angular/core'; +import { NotifyStatuses } from '../notify-requests-status.model'; +import { RequestStatusEnum } from '../notify-status.enum'; + +@Component({ + selector: 'ds-request-status-alert-box', + templateUrl: './request-status-alert-box.component.html', + styleUrls: ['./request-status-alert-box.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +/** + * Represents a component that displays the status of a request. + */ +export class RequestStatusAlertBoxComponent implements OnInit { + /** + * The status of the request. + */ + @Input() status: RequestStatusEnum; + + /** + * The input data for the request status alert box component. + * @type {NotifyStatuses[]} + */ + @Input() data: NotifyStatuses[] = []; + + /** + * The display options for the request status alert box. + */ + displayOptions: NotifyRequestDisplayOptions; + + ngOnInit(): void { + this.prepareDataToDisplay(); + } + + /** + * Prepares the data to be displayed based on the current status. + */ + private prepareDataToDisplay() { + switch (this.status) { + case RequestStatusEnum.ACCEPTED: + this.displayOptions = { + alertType: 'alert-info', + text: 'request-status-alert-box.accepted', + }; + break; + + case RequestStatusEnum.REJECTED: + this.displayOptions = { + alertType: 'alert-danger', + text: 'request-status-alert-box.rejected', + }; + break; + + case RequestStatusEnum.REQUESTED: + this.displayOptions = { + alertType: 'alert-warning', + text: 'request-status-alert-box.requested', + }; + break; + } + } +} + +/** + * Represents the display options for a notification request. + */ +export interface NotifyRequestDisplayOptions { + /** + * The type of alert to display. + * Possible values are 'alert-danger', 'alert-warning', or 'alert-info'. + */ + alertType: 'alert-danger' | 'alert-warning' | 'alert-info'; + /** + * The text to display in the notification. + */ + text: string; +} diff --git a/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html new file mode 100644 index 00000000000..77370f462d8 --- /dev/null +++ b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html @@ -0,0 +1,22 @@ + + +
+
+ +
+
+
+ {{'item.qa-event-notification.check.notification-info' | translate : {num: source.totalEvents } }} +
+ +
+
+
+
diff --git a/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.scss b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.scss new file mode 100644 index 00000000000..2a62342b7c9 --- /dev/null +++ b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.scss @@ -0,0 +1,13 @@ +.source-logo { + max-height: var(--ds-header-logo-height); +} + +.source-logo-container { + width: var(--ds-qa-logo-width); + display: flex; + justify-content: center; +} + +.sections-gap { + gap: 1rem; +} diff --git a/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.spec.ts b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.spec.ts new file mode 100644 index 00000000000..ce231affeea --- /dev/null +++ b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { QaEventNotificationComponent } from './qa-event-notification.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { QualityAssuranceSourceDataService } from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { of } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { SplitPipe } from 'src/app/shared/utils/split.pipe'; + +describe('QaEventNotificationComponent', () => { + let component: QaEventNotificationComponent; + let fixture: ComponentFixture; + let qualityAssuranceSourceDataServiceStub: any; + + const obj = Object.assign(new QualityAssuranceSourceObject(), { + id: 'sourceName:target', + source: 'sourceName', + target: 'target', + totalEvents: 1 + }); + + const objPL = createSuccessfulRemoteDataObject$(createPaginatedList([obj])); + const item = Object.assign({ uuid: '1234' }); + beforeEach(async () => { + + qualityAssuranceSourceDataServiceStub = { + getSourcesByTarget: () => objPL + }; + await TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot()], + declarations: [QaEventNotificationComponent, SplitPipe], + providers: [ + { provide: QualityAssuranceSourceDataService, useValue: qualityAssuranceSourceDataServiceStub }, + { provide: RequestService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('test') }, + ObjectCacheService, + RemoteDataBuildService, + provideMockStore({}) + ], + }) + .compileComponents(); + fixture = TestBed.createComponent(QaEventNotificationComponent); + component = fixture.componentInstance; + component.item = item; + component.sources$ = of([obj]); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display sources if present', () => { + const alertElements = fixture.debugElement.queryAll(By.css('.alert')); + expect(alertElements.length).toBe(1); + }); + + it('should return the quality assurance route when getQualityAssuranceRoute is called', () => { + const route = component.getQualityAssuranceRoute(); + expect(route).toBe('/notifications/quality-assurance'); + }); +}); diff --git a/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.ts b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.ts new file mode 100644 index 00000000000..1557a65a0e6 --- /dev/null +++ b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.ts @@ -0,0 +1,76 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { Observable } from 'rxjs'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { QualityAssuranceSourceDataService } from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { catchError, map } from 'rxjs/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { getNotificatioQualityAssuranceRoute } from '../../../admin/admin-routing-paths'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; + +@Component({ + selector: 'ds-qa-event-notification', + templateUrl: './qa-event-notification.component.html', + styleUrls: ['./qa-event-notification.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [QualityAssuranceSourceDataService] +}) +/** + * Component for displaying quality assurance event notifications for an item. + */ +export class QaEventNotificationComponent implements OnChanges { + /** + * The item to display quality assurance event notifications for. + */ + @Input() item: Item; + + /** + * An observable that emits an array of QualityAssuranceSourceObject. + */ + sources$: Observable; + + constructor( + private qualityAssuranceSourceDataService: QualityAssuranceSourceDataService, + ) {} + + /** + * Detect changes to the item input and update the sources$ observable. + */ + ngOnChanges(changes: SimpleChanges): void { + if (changes.item && changes.item.currentValue.uuid !== changes.item.previousValue?.uuid) { + this.sources$ = this.getQualityAssuranceSources$(); + } + } + /** + * Returns an Observable of QualityAssuranceSourceObject[] for the current item. + * @returns An Observable of QualityAssuranceSourceObject[] for the current item. + * Note: sourceId is composed as: id: "sourceName:" + */ + getQualityAssuranceSources$(): Observable { + const findListTopicOptions: FindListOptions = { + searchParams: [new RequestParam('target', this.item.uuid)] + }; + return this.qualityAssuranceSourceDataService.getSourcesByTarget(findListTopicOptions, false) + .pipe( + getFirstCompletedRemoteData(), + map((data: RemoteData>) => { + if (data.hasSucceeded) { + return data.payload.page; + } + return []; + }), + catchError(() => []) + ); + } + + /** + * Returns the quality assurance route. + * @returns The quality assurance route. + */ + getQualityAssuranceRoute(): string { + return getNotificatioQualityAssuranceRoute(); + } +} diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts index 838d5a53c5b..9f812477b52 100644 --- a/src/app/menu.resolver.spec.ts +++ b/src/app/menu.resolver.spec.ts @@ -19,6 +19,8 @@ import { cold } from 'jasmine-marbles'; import createSpy = jasmine.createSpy; import { createSuccessfulRemoteDataObject$ } from './shared/remote-data.utils'; import { createPaginatedList } from './shared/testing/utils.test'; +import { ConfigurationDataService } from './core/data/configuration-data.service'; +import { ConfigurationDataServiceStub } from './shared/testing/configuration-data.service.stub'; const BOOLEAN = { t: true, f: false }; const MENU_STATE = { @@ -37,6 +39,7 @@ describe('MenuResolver', () => { let browseService; let authorizationService; let scriptService; + let configurationDataService; beforeEach(waitForAsync(() => { menuService = new MenuServiceStub(); @@ -53,6 +56,9 @@ describe('MenuResolver', () => { scriptWithNameExistsAndCanExecute: observableOf(true) }); + configurationDataService = new ConfigurationDataServiceStub(); + spyOn(configurationDataService, 'findByPropertyName').and.returnValue(observableOf(true)); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule], declarations: [AdminSidebarComponent], @@ -61,6 +67,7 @@ describe('MenuResolver', () => { { provide: BrowseService, useValue: browseService }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: ScriptDataService, useValue: scriptService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, { provide: NgbModal, useValue: { open: () => {/*comment*/ diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index cad6a6ec570..807251ca41c 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -47,6 +47,8 @@ import { import { ExportBatchSelectorComponent } from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component'; +import { PUBLICATION_CLAIMS_PATH } from './admin/admin-notifications/admin-notifications-routing-paths'; +import { ConfigurationDataService } from './core/data/configuration-data.service'; /** * Creates all of the app's menus @@ -61,6 +63,7 @@ export class MenuResolver implements Resolve { protected authorizationService: AuthorizationDataService, protected modalService: NgbModal, protected scriptDataService: ScriptDataService, + protected configurationDataService: ConfigurationDataService ) { } @@ -170,7 +173,9 @@ export class MenuResolver implements Resolve { this.authorizationService.isAuthorized(FeatureID.AdministratorOf), this.authorizationService.isAuthorized(FeatureID.CanSubmit), this.authorizationService.isAuthorized(FeatureID.CanEditItem), - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem]) => { + this.authorizationService.isAuthorized(FeatureID.CanSeeQA), + this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled), + ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem, canSeeQa, isCoarNotifyEnabled]) => { const newSubMenuList = [ { id: 'new_community', @@ -221,6 +226,18 @@ export class MenuResolver implements Resolve { text: 'menu.section.new_process', link: '/processes/new' } as LinkMenuItemModel, + },/* ldn_services */ + { + id: 'ldn_services_new', + parentID: 'new', + active: false, + visible: isSiteAdmin && isCoarNotifyEnabled, + model: { + type: MenuItemType.LINK, + text: 'menu.section.services_new', + link: '/admin/ldn/services/new' + } as LinkMenuItemModel, + icon: '', }, ]; const editSubMenuList = [ @@ -349,6 +366,41 @@ export class MenuResolver implements Resolve { icon: 'terminal', index: 10 }, + /* COAR Notify section */ + { + id: 'coar_notify', + active: false, + visible: isSiteAdmin && isCoarNotifyEnabled, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.coar_notify' + } as TextMenuItemModel, + icon: 'inbox', + index: 13 + }, + { + id: 'notify_dashboard', + active: false, + parentID: 'coar_notify', + visible: isSiteAdmin && isCoarNotifyEnabled, + model: { + type: MenuItemType.LINK, + text: 'menu.section.notify_dashboard', + link: '/admin/notify-dashboard' + } as LinkMenuItemModel, + }, + /* LDN Services */ + { + id: 'ldn_services', + active: false, + parentID: 'coar_notify', + visible: isSiteAdmin && isCoarNotifyEnabled, + model: { + type: MenuItemType.LINK, + text: 'menu.section.services', + link: '/admin/ldn/services' + } as LinkMenuItemModel, + }, { id: 'health', active: false, @@ -361,6 +413,41 @@ export class MenuResolver implements Resolve { icon: 'heartbeat', index: 11 }, + /* Notifications */ + { + id: 'notifications', + active: false, + visible: canSeeQa || isSiteAdmin, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.notifications' + } as TextMenuItemModel, + icon: 'bell', + index: 4 + }, + { + id: 'notifications_quality-assurance', + parentID: 'notifications', + active: false, + visible: canSeeQa, + model: { + type: MenuItemType.LINK, + text: 'menu.section.quality-assurance', + link: '/notifications/quality-assurance' + } as LinkMenuItemModel, + }, + { + id: 'notifications_publication-claim', + parentID: 'notifications', + active: false, + visible: isSiteAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.notifications_publication-claim', + link: '/admin/notifications/' + PUBLICATION_CLAIMS_PATH + } as LinkMenuItemModel, + }, + /* Admin Search */ ]; menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { shouldPersistOnRouteChange: true diff --git a/src/app/my-dspace-page/my-dspace-page.component.html b/src/app/my-dspace-page/my-dspace-page.component.html index ea5784170fb..cfae8e07a86 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.html +++ b/src/app/my-dspace-page/my-dspace-page.component.html @@ -1,4 +1,6 @@
+ +
diff --git a/src/app/my-dspace-page/my-dspace-page.module.ts b/src/app/my-dspace-page/my-dspace-page.module.ts index 6ad50af96a5..a3390b90b2c 100644 --- a/src/app/my-dspace-page/my-dspace-page.module.ts +++ b/src/app/my-dspace-page/my-dspace-page.module.ts @@ -15,6 +15,10 @@ import { MyDSpaceNewExternalDropdownComponent } from './my-dspace-new-submission import { ThemedMyDSpacePageComponent } from './themed-my-dspace-page.component'; import { SearchModule } from '../shared/search/search.module'; import { UploadModule } from '../shared/upload/upload.module'; +import { + MyDspaceQaEventsNotificationsComponent +} from './my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component'; +import { NotificationsModule } from '../notifications/notifications.module'; const DECLARATIONS = [ MyDSpacePageComponent, @@ -22,7 +26,8 @@ const DECLARATIONS = [ MyDSpaceNewSubmissionComponent, CollectionSelectorComponent, MyDSpaceNewSubmissionDropdownComponent, - MyDSpaceNewExternalDropdownComponent + MyDSpaceNewExternalDropdownComponent, + MyDspaceQaEventsNotificationsComponent ]; @NgModule({ @@ -33,6 +38,7 @@ const DECLARATIONS = [ MyDspacePageRoutingModule, MyDspaceSearchModule.withEntryComponents(), UploadModule, + NotificationsModule ], declarations: DECLARATIONS, providers: [ diff --git a/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.html b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.html new file mode 100644 index 00000000000..e44d2c6e489 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.html @@ -0,0 +1,29 @@ + + +
+
+ +
+
+
+ {{ "mydspace.qa-event-notification.check.notification-info" | translate : { num: source.totalEvents } }} +
+ +
+
+
+
diff --git a/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.scss b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.scss new file mode 100644 index 00000000000..2a62342b7c9 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.scss @@ -0,0 +1,13 @@ +.source-logo { + max-height: var(--ds-header-logo-height); +} + +.source-logo-container { + width: var(--ds-qa-logo-width); + display: flex; + justify-content: center; +} + +.sections-gap { + gap: 1rem; +} diff --git a/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.spec.ts b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.spec.ts new file mode 100644 index 00000000000..99d2b601a25 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MyDspaceQaEventsNotificationsComponent } from './my-dspace-qa-events-notifications.component'; +import { QualityAssuranceSourceDataService } from '../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils'; +import { createPaginatedList } from 'src/app/shared/testing/utils.test'; +import { QualityAssuranceSourceObject } from 'src/app/core/notifications/qa/models/quality-assurance-source.model'; + +describe('MyDspaceQaEventsNotificationsComponent', () => { + let component: MyDspaceQaEventsNotificationsComponent; + let fixture: ComponentFixture; + + let qualityAssuranceSourceDataServiceStub: any; + const obj = createSuccessfulRemoteDataObject$(createPaginatedList([new QualityAssuranceSourceObject()])); + + beforeEach(async () => { + qualityAssuranceSourceDataServiceStub = { + getSources: () => obj + }; + await TestBed.configureTestingModule({ + declarations: [ MyDspaceQaEventsNotificationsComponent ], + providers: [ + { provide: QualityAssuranceSourceDataService, useValue: qualityAssuranceSourceDataServiceStub } + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(MyDspaceQaEventsNotificationsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.ts b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.ts new file mode 100644 index 00000000000..2c5f34cbd7d --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component.ts @@ -0,0 +1,53 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { QualityAssuranceSourceDataService } from '../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { getFirstCompletedRemoteData, getPaginatedListPayload, getRemoteDataPayload } from '../../core/shared/operators'; +import { Observable, of, tap } from 'rxjs'; +import { getNotificatioQualityAssuranceRoute } from '../../admin/admin-routing-paths'; +import { QualityAssuranceSourceObject } from 'src/app/core/notifications/qa/models/quality-assurance-source.model'; + +@Component({ + selector: 'ds-my-dspace-qa-events-notifications', + templateUrl: './my-dspace-qa-events-notifications.component.html', + styleUrls: ['./my-dspace-qa-events-notifications.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MyDspaceQaEventsNotificationsComponent implements OnInit { + + /** + * An Observable that emits an array of QualityAssuranceSourceObject. + */ + sources$: Observable = of([]); + + constructor(private qualityAssuranceSourceDataService: QualityAssuranceSourceDataService) { } + + ngOnInit(): void { + this.getSources(); + } + + /** + * Retrieves the sources for Quality Assurance. + * @returns An Observable of the sources for Quality Assurance. + * @throws An error if the retrieval of Quality Assurance sources fails. + */ + getSources() { + this.sources$ = this.qualityAssuranceSourceDataService.getSources() + .pipe( + getFirstCompletedRemoteData(), + tap((rd) => { + if (rd.hasFailed) { + throw new Error('Can\'t retrieve Quality Assurance sources'); + } + }), + getRemoteDataPayload(), + getPaginatedListPayload(), + ); + } + + /** + * Retrieves the quality assurance route. + * @returns The quality assurance route. + */ + getQualityAssuranceRoute(): string { + return getNotificatioQualityAssuranceRoute(); + } +} diff --git a/src/app/notifications/notifications-effects.ts b/src/app/notifications/notifications-effects.ts new file mode 100644 index 00000000000..9c446cb66ad --- /dev/null +++ b/src/app/notifications/notifications-effects.ts @@ -0,0 +1,9 @@ +import { QualityAssuranceSourceEffects } from './qa/source/quality-assurance-source.effects'; +import { QualityAssuranceTopicsEffects } from './qa/topics/quality-assurance-topics.effects'; +import { SuggestionTargetsEffects } from './suggestion-targets/suggestion-targets.effects'; + +export const notificationsEffects = [ + QualityAssuranceTopicsEffects, + QualityAssuranceSourceEffects, + SuggestionTargetsEffects +]; diff --git a/src/app/notifications/notifications-state.service.spec.ts b/src/app/notifications/notifications-state.service.spec.ts new file mode 100644 index 00000000000..324710ad09b --- /dev/null +++ b/src/app/notifications/notifications-state.service.spec.ts @@ -0,0 +1,541 @@ +import { TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { cold } from 'jasmine-marbles'; +import { suggestionNotificationsReducers } from './notifications.reducer'; +import { NotificationsStateService } from './notifications-state.service'; +import { + qualityAssuranceSourceObjectMissingPid, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid, + qualityAssuranceTopicObjectMissingPid, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../shared/mocks/notifications.mock'; +import { RetrieveAllTopicsAction } from './qa/topics/quality-assurance-topics.actions'; +import { RetrieveAllSourceAction } from './qa/source/quality-assurance-source.actions'; + +describe('NotificationsStateService', () => { + let service: NotificationsStateService; + let serviceAsAny: any; + let store: any; + let initialState: any; + + describe('Topis State', () => { + function init(mode: string) { + if (mode === 'empty') { + initialState = { + suggestionNotifications: { + qaTopic: { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0, + totalLoadedPages: 0 + } + } + }; + } else { + initialState = { + suggestionNotifications: { + qaTopic: { + topics: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMissingPid + ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 1, + totalElements: 3, + totalLoadedPages: 1 + } + } + }; + } + } + + describe('Testing methods with empty topic objects', () => { + beforeEach(async () => { + init('empty'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ suggestionNotifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: NotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new NotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceTopics', () => { + it('Should return an empty array', () => { + const result = service.getQualityAssuranceTopics(); + const expected = cold('(a)', { + a: [] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotalPages', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceTopicsTotalPages(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsCurrentPage', () => { + it('Should return minus one (0)', () => { + const result = service.getQualityAssuranceTopicsCurrentPage(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotals', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceTopicsTotals(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoading', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceTopicsLoading(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoaded', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsLoaded(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing methods with topic objects', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ suggestionNotifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: NotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new NotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceTopics', () => { + it('Should return an array of topics', () => { + const result = service.getQualityAssuranceTopics(); + const expected = cold('(a)', { + a: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMissingPid + ] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotalPages', () => { + it('Should return one (1)', () => { + const result = service.getQualityAssuranceTopicsTotalPages(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsCurrentPage', () => { + it('Should return minus zero (1)', () => { + const result = service.getQualityAssuranceTopicsCurrentPage(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotals', () => { + it('Should return three (3)', () => { + const result = service.getQualityAssuranceTopicsTotals(); + const expected = cold('(a)', { + a: 3 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoading', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsLoading(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoaded', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceTopicsLoaded(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing the topic dispatch methods', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ suggestionNotifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: NotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new NotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('dispatchRetrieveQualityAssuranceTopics', () => { + it('Should call store.dispatch', () => { + const elementsPerPage = 3; + const currentPage = 1; + const action = new RetrieveAllTopicsAction(elementsPerPage, currentPage, 'source', 'target'); + service.dispatchRetrieveQualityAssuranceTopics(elementsPerPage, currentPage, 'source', 'target'); + expect(serviceAsAny.store.dispatch).toHaveBeenCalledWith(action); + }); + }); + }); + }); + + describe('Source State', () => { + function init(mode: string) { + if (mode === 'empty') { + initialState = { + suggestionNotifications: { + qaSource: { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0, + totalLoadedPages: 0 + } + } + }; + } else { + initialState = { + suggestionNotifications: { + qaSource: { + source: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMissingPid + ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 1, + totalElements: 3, + totalLoadedPages: 1 + } + } + }; + } + } + + describe('Testing methods with empty source objects', () => { + beforeEach(async () => { + init('empty'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ suggestionNotifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: NotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new NotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceSource', () => { + it('Should return an empty array', () => { + const result = service.getQualityAssuranceSource(); + const expected = cold('(a)', { + a: [] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotalPages', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceSourceTotalPages(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourcesCurrentPage', () => { + it('Should return minus one (0)', () => { + const result = service.getQualityAssuranceSourceCurrentPage(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotals', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceSourceTotals(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoading', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceSourceLoading(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoaded', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceLoaded(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing methods with Source objects', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ suggestionNotifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: NotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new NotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceSource', () => { + it('Should return an array of Source', () => { + const result = service.getQualityAssuranceSource(); + const expected = cold('(a)', { + a: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMissingPid + ] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotalPages', () => { + it('Should return one (1)', () => { + const result = service.getQualityAssuranceSourceTotalPages(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceCurrentPage', () => { + it('Should return minus zero (1)', () => { + const result = service.getQualityAssuranceSourceCurrentPage(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotals', () => { + it('Should return three (3)', () => { + const result = service.getQualityAssuranceSourceTotals(); + const expected = cold('(a)', { + a: 3 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoading', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceLoading(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoaded', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceSourceLoaded(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing the Source dispatch methods', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ suggestionNotifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: NotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new NotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('dispatchRetrieveQualityAssuranceSource', () => { + it('Should call store.dispatch', () => { + const elementsPerPage = 3; + const currentPage = 1; + const action = new RetrieveAllSourceAction(elementsPerPage, currentPage); + service.dispatchRetrieveQualityAssuranceSource(elementsPerPage, currentPage); + expect(serviceAsAny.store.dispatch).toHaveBeenCalledWith(action); + }); + }); + }); + }); + + +}); diff --git a/src/app/notifications/notifications-state.service.ts b/src/app/notifications/notifications-state.service.ts new file mode 100644 index 00000000000..3cdaa589d62 --- /dev/null +++ b/src/app/notifications/notifications-state.service.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + getQualityAssuranceTopicsCurrentPageSelector, + getQualityAssuranceTopicsTotalPagesSelector, + getQualityAssuranceTopicsTotalsSelector, + isQualityAssuranceTopicsLoadedSelector, + qualityAssuranceTopicsObjectSelector, + isQualityAssuranceTopicsProcessingSelector, + qualityAssuranceSourceObjectSelector, + isQualityAssuranceSourceLoadedSelector, + isQualityAssuranceSourceProcessingSelector, + getQualityAssuranceSourceTotalPagesSelector, + getQualityAssuranceSourceCurrentPageSelector, + getQualityAssuranceSourceTotalsSelector +} from './selectors'; +import { QualityAssuranceTopicObject } from '../core/notifications/qa/models/quality-assurance-topic.model'; +import { SuggestionNotificationsState } from './notifications.reducer'; +import { RetrieveAllTopicsAction } from './qa/topics/quality-assurance-topics.actions'; +import { QualityAssuranceSourceObject } from '../core/notifications/qa/models/quality-assurance-source.model'; +import { RetrieveAllSourceAction } from './qa/source/quality-assurance-source.actions'; + +/** + * The service handling the Notifications State. + */ +@Injectable() +export class NotificationsStateService { + + /** + * Initialize the service variables. + * @param {Store} store + */ + constructor(private store: Store) { } + + // Quality Assurance topics + // -------------------------------------------------------------------------- + + /** + * Returns the list of Quality Assurance topics from the state. + * + * @return Observable + * The list of Quality Assurance topics. + */ + public getQualityAssuranceTopics(): Observable { + return this.store.pipe(select(qualityAssuranceTopicsObjectSelector())); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if the topics are loading, 'false' otherwise. + */ + public isQualityAssuranceTopicsLoading(): Observable { + return this.store.pipe( + select(isQualityAssuranceTopicsLoadedSelector), + map((loaded: boolean) => !loaded) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (whether or not they were loaded). + * + * @return Observable + * 'true' if the topics are loaded, 'false' otherwise. + */ + public isQualityAssuranceTopicsLoaded(): Observable { + return this.store.pipe(select(isQualityAssuranceTopicsLoadedSelector)); + } + + /** + * Returns the information about the processing status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the topics (ex.: a REST call), 'false' otherwise. + */ + public isQualityAssuranceTopicsProcessing(): Observable { + return this.store.pipe(select(isQualityAssuranceTopicsProcessingSelector)); + } + + /** + * Returns, from the state, the total available pages of the Quality Assurance topics. + * + * @return Observable + * The number of the Quality Assurance topics pages. + */ + public getQualityAssuranceTopicsTotalPages(): Observable { + return this.store.pipe(select(getQualityAssuranceTopicsTotalPagesSelector)); + } + + /** + * Returns the current page of the Quality Assurance topics, from the state. + * + * @return Observable + * The number of the current Quality Assurance topics page. + */ + public getQualityAssuranceTopicsCurrentPage(): Observable { + return this.store.pipe(select(getQualityAssuranceTopicsCurrentPageSelector)); + } + + /** + * Returns the total number of the Quality Assurance topics. + * + * @return Observable + * The number of the Quality Assurance topics. + */ + public getQualityAssuranceTopicsTotals(): Observable { + return this.store.pipe(select(getQualityAssuranceTopicsTotalsSelector)); + } + + /** + * Dispatch a request to change the Quality Assurance topics state, retrieving the topics from the server. + * + * @param elementsPerPage + * The number of the topics per page. + * @param currentPage + * The number of the current page. + */ + public dispatchRetrieveQualityAssuranceTopics(elementsPerPage: number, currentPage: number, sourceId: string, targteId?: string): void { + this.store.dispatch(new RetrieveAllTopicsAction(elementsPerPage, currentPage, sourceId, targteId)); + } + + // Quality Assurance source + // -------------------------------------------------------------------------- + + /** + * Returns the list of Quality Assurance source from the state. + * + * @return Observable + * The list of Quality Assurance source. + */ + public getQualityAssuranceSource(): Observable { + return this.store.pipe(select(qualityAssuranceSourceObjectSelector())); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if the source are loading, 'false' otherwise. + */ + public isQualityAssuranceSourceLoading(): Observable { + return this.store.pipe( + select(isQualityAssuranceSourceLoadedSelector), + map((loaded: boolean) => !loaded) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (whether or not they were loaded). + * + * @return Observable + * 'true' if the source are loaded, 'false' otherwise. + */ + public isQualityAssuranceSourceLoaded(): Observable { + return this.store.pipe(select(isQualityAssuranceSourceLoadedSelector)); + } + + /** + * Returns the information about the processing status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the source (ex.: a REST call), 'false' otherwise. + */ + public isQualityAssuranceSourceProcessing(): Observable { + return this.store.pipe(select(isQualityAssuranceSourceProcessingSelector)); + } + + /** + * Returns, from the state, the total available pages of the Quality Assurance source. + * + * @return Observable + * The number of the Quality Assurance source pages. + */ + public getQualityAssuranceSourceTotalPages(): Observable { + return this.store.pipe(select(getQualityAssuranceSourceTotalPagesSelector)); + } + + /** + * Returns the current page of the Quality Assurance source, from the state. + * + * @return Observable + * The number of the current Quality Assurance source page. + */ + public getQualityAssuranceSourceCurrentPage(): Observable { + return this.store.pipe(select(getQualityAssuranceSourceCurrentPageSelector)); + } + + /** + * Returns the total number of the Quality Assurance source. + * + * @return Observable + * The number of the Quality Assurance source. + */ + public getQualityAssuranceSourceTotals(): Observable { + return this.store.pipe(select(getQualityAssuranceSourceTotalsSelector)); + } + + /** + * Dispatch a request to change the Quality Assurance source state, retrieving the source from the server. + * + * @param elementsPerPage + * The number of the source per page. + * @param currentPage + * The number of the current page. + */ + public dispatchRetrieveQualityAssuranceSource(elementsPerPage: number, currentPage: number): void { + this.store.dispatch(new RetrieveAllSourceAction(elementsPerPage, currentPage)); + } +} diff --git a/src/app/notifications/notifications.module.ts b/src/app/notifications/notifications.module.ts new file mode 100644 index 00000000000..adc86f527c0 --- /dev/null +++ b/src/app/notifications/notifications.module.ts @@ -0,0 +1,113 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Action, StoreConfig, StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; + +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; +import { storeModuleConfig } from '../app.reducer'; +import { QualityAssuranceTopicsComponent } from './qa/topics/quality-assurance-topics.component'; +import { QualityAssuranceEventsComponent } from './qa/events/quality-assurance-events.component'; +import { NotificationsStateService } from './notifications-state.service'; +import { suggestionNotificationsReducers, SuggestionNotificationsState } from './notifications.reducer'; +import { notificationsEffects } from './notifications-effects'; +import { QualityAssuranceTopicsService } from './qa/topics/quality-assurance-topics.service'; +import { + QualityAssuranceTopicDataService +} from '../core/notifications/qa/topics/quality-assurance-topic-data.service'; +import { + QualityAssuranceEventDataService +} from '../core/notifications/qa/events/quality-assurance-event-data.service'; +import { ProjectEntryImportModalComponent } from './qa/project-entry-import-modal/project-entry-import-modal.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchModule } from '../shared/search/search.module'; +import { QualityAssuranceSourceComponent } from './qa/source/quality-assurance-source.component'; +import { QualityAssuranceSourceService } from './qa/source/quality-assurance-source.service'; +import { + QualityAssuranceSourceDataService +} from '../core/notifications/qa/source/quality-assurance-source-data.service'; +import { EPersonDataComponent } from './qa/events/ePerson-data/ePerson-data.component'; +import { SuggestionActionsComponent } from './suggestion-actions/suggestion-actions.component'; +import { PublicationClaimComponent } from './suggestion-targets/publication-claim/publication-claim.component'; +import { SuggestionListElementComponent } from './suggestion-list-element/suggestion-list-element.component'; +import { + SuggestionEvidencesComponent +} from './suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; +import { SuggestionsPopupComponent } from './suggestions-popup/suggestions-popup.component'; +import { SuggestionsNotificationComponent } from './suggestions-notification/suggestions-notification.component'; +import { SuggestionsService } from './suggestions.service'; +import { SuggestionSourceDataService } from '../core/notifications/source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; +import { SuggestionTargetsStateService } from './suggestion-targets/suggestion-targets.state.service'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; + + +const MODULES = [ + CommonModule, + SharedModule, + SearchModule, + CoreModule.forRoot(), + StoreModule.forFeature('suggestionNotifications', suggestionNotificationsReducers, storeModuleConfig as StoreConfig), + EffectsModule.forFeature(notificationsEffects), + TranslateModule +]; + +const COMPONENTS = [ + QualityAssuranceTopicsComponent, + QualityAssuranceEventsComponent, + QualityAssuranceSourceComponent, + EPersonDataComponent, + PublicationClaimComponent, + SuggestionActionsComponent, + SuggestionListElementComponent, + SuggestionEvidencesComponent, + SuggestionsPopupComponent, + SuggestionsNotificationComponent +]; + +const DIRECTIVES = [ ]; + +const ENTRY_COMPONENTS = [ + ProjectEntryImportModalComponent +]; + +const PROVIDERS = [ + NotificationsStateService, + QualityAssuranceTopicsService, + QualityAssuranceSourceService, + QualityAssuranceTopicDataService, + QualityAssuranceSourceDataService, + QualityAssuranceEventDataService, + SuggestionsService, + SuggestionSourceDataService, + SuggestionTargetDataService, + SuggestionTargetsStateService, + SuggestionsDataService +]; + +@NgModule({ + imports: [ + ...MODULES + ], + declarations: [ + ...COMPONENTS, + ...DIRECTIVES, + ...ENTRY_COMPONENTS, + ], + providers: [ + ...PROVIDERS + ], + entryComponents: [ + ...ENTRY_COMPONENTS + ], + exports: [ + ...COMPONENTS, + ...DIRECTIVES, + ] +}) + +/** + * This module handles all components that are necessary for the OpenAIRE components + */ +export class NotificationsModule { +} diff --git a/src/app/notifications/notifications.reducer.ts b/src/app/notifications/notifications.reducer.ts new file mode 100644 index 00000000000..665c5d73929 --- /dev/null +++ b/src/app/notifications/notifications.reducer.ts @@ -0,0 +1,21 @@ +import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; +import { qualityAssuranceSourceReducer, QualityAssuranceSourceState } from './qa/source/quality-assurance-source.reducer'; +import { qualityAssuranceTopicsReducer, QualityAssuranceTopicState, } from './qa/topics/quality-assurance-topics.reducer'; +import { SuggestionTargetsReducer, SuggestionTargetState } from './suggestion-targets/suggestion-targets.reducer'; + +/** + * The OpenAIRE State + */ +export interface SuggestionNotificationsState { + 'qaTopic': QualityAssuranceTopicState; + 'qaSource': QualityAssuranceSourceState; + 'suggestionTarget': SuggestionTargetState; +} + +export const suggestionNotificationsReducers: ActionReducerMap = { + qaTopic: qualityAssuranceTopicsReducer, + qaSource: qualityAssuranceSourceReducer, + suggestionTarget: SuggestionTargetsReducer +}; + +export const suggestionNotificationsSelector = createFeatureSelector('suggestionNotifications'); diff --git a/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.html b/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.html new file mode 100644 index 00000000000..058457fd40c --- /dev/null +++ b/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.html @@ -0,0 +1,10 @@ + + + + + {{ ePersonData[property] }} + +
+
+
+
diff --git a/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.spec.ts b/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.spec.ts new file mode 100644 index 00000000000..6fad8dbc922 --- /dev/null +++ b/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.spec.ts @@ -0,0 +1,58 @@ +/* tslint:disable:no-unused-variable */ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { EPersonDataComponent } from './ePerson-data.component'; +import { EPersonDataService } from './../../../../core/eperson/eperson-data.service'; +import { EPerson } from 'src/app/core/eperson/models/eperson.model'; +import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils'; + +describe('EPersonDataComponent', () => { + let component: EPersonDataComponent; + let fixture: ComponentFixture; + let ePersonDataService = jasmine.createSpyObj('EPersonDataService', ['findById']); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ EPersonDataComponent ], + providers: [ { + provide: EPersonDataService, + useValue: ePersonDataService + } ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EPersonDataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve EPerson data when ePersonId is provided', () => { + const ePersonId = '123'; + const ePersonData = Object.assign(new EPerson(), { + id: ePersonId, + email: 'john.doe@domain.com', + metadata: [ + { + key: 'eperson.firstname', + value: 'John' + }, + { + key: 'eperson.lastname', + value: 'Doe' + } + ] + }); + const ePersonDataRD$ = createSuccessfulRemoteDataObject$(ePersonData); + ePersonDataService.findById.and.returnValue(ePersonDataRD$); + component.ePersonId = ePersonId; + component.getEPersonData$(); + fixture.detectChanges(); + expect(ePersonDataService.findById).toHaveBeenCalledWith(ePersonId, true); + }); +}); diff --git a/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.ts b/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.ts new file mode 100644 index 00000000000..51fd31a1393 --- /dev/null +++ b/src/app/notifications/qa/events/ePerson-data/ePerson-data.component.ts @@ -0,0 +1,44 @@ +import { Component, Input } from '@angular/core'; +import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators'; +import { Observable } from 'rxjs'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; + +@Component({ + selector: 'ds-eperson-data', + templateUrl: './ePerson-data.component.html' +}) +/** + * Represents the component for displaying ePerson data. + */ +export class EPersonDataComponent { + + /** + * The ID of the ePerson. + */ + @Input() ePersonId: string; + + /** + * The properties of the ePerson to display. + */ + @Input() properties: string[]; + + /** + * Creates an instance of the EPersonDataComponent. + * @param ePersonDataService The service for retrieving ePerson data. + */ + constructor(private ePersonDataService: EPersonDataService) { } + + /** + * Retrieves the EPerson data based on the provided ePersonId. + * @returns An Observable that emits the EPerson data. + */ + getEPersonData$(): Observable { + if (this.ePersonId) { + return this.ePersonDataService.findById(this.ePersonId, true).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload() + ); + } + } +} diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.html b/src/app/notifications/qa/events/quality-assurance-events.component.html new file mode 100644 index 00000000000..8f0bd4323f0 --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.html @@ -0,0 +1,287 @@ +
+ +
+
+

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

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{'quality-assurance.event.table.trust' | translate}}{{'quality-assurance.event.table.publication' | translate}} + {{'quality-assurance.event.table.project-details' | translate}} + + {{'quality-assurance.event.table.reasons' | translate}} + + {{'quality-assurance.event.table.person-who-requested' | translate}} + {{'quality-assurance.event.table.actions' | translate}}
{{eventElement?.event?.trust}} + {{eventElement.title}} + {{eventElement.title}} +
+ {{'quality-assurance.event.table.event.message.serviceUrl' | translate}} + + {{eventElement.event.message.serviceId}} + +
+
+ {{'quality-assurance.event.table.event.message.link' | translate}} + + {{eventElement.event.message.href}} + +
+
+

{{'quality-assurance.event.table.pidtype' | translate}} {{eventElement.event.message.type}}

+

{{'quality-assurance.event.table.pidvalue' | translate}}
+ + {{eventElement.event.message.value}} + + {{eventElement.event.message.value}} +

+
+

{{'quality-assurance.event.table.subjectValue' | translate}} +
{{eventElement.event.message.value}}

+
+

+ {{'quality-assurance.event.table.abstract' | translate}}
+ {{eventElement.event.message.abstract}} +

+ +
+

+ + {{eventElement.event.message.reason}}
+
+

+
+

+ + + +

+
+

+ {{'quality-assurance.event.table.suggestedProject' | translate}} +

+

+ {{'quality-assurance.event.table.project' | translate}}
+ {{eventElement.event.message.title}} +

+

+ {{'quality-assurance.event.table.acronym' | translate}} {{eventElement.event.message.acronym}}
+ {{'quality-assurance.event.table.code' | translate}} {{eventElement.event.message.code}}
+ {{'quality-assurance.event.table.funder' | translate}} {{eventElement.event.message.funder}}
+ {{'quality-assurance.event.table.fundingProgram' | translate}} {{eventElement.event.message.fundingProgram}}
+ {{'quality-assurance.event.table.jurisdiction' | translate}} {{eventElement.event.message.jurisdiction}} +

+
+
+ {{(eventElement.hasProject ? 'quality-assurance.event.project.found' : 'quality-assurance.event.project.notFound') | translate}} + {{eventElement.handle}} +
+ + +
+
+
+
+ + + + +
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.scss b/src/app/notifications/qa/events/quality-assurance-events.component.scss new file mode 100644 index 00000000000..29c16328c35 --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.scss @@ -0,0 +1,28 @@ +.button-col, .trust-col { + width: 15%; +} + +.title-col { + width: 30%; +} +.content-col { + width: 40%; +} + +.button-width { + width: 100%; +} + +.abstract-container { + height: 76px; + overflow: hidden; +} + +.text-ellipsis { + text-overflow: ellipsis; +} + +.show { + overflow: visible; + height: auto; +} diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.spec.ts b/src/app/notifications/qa/events/quality-assurance-events.component.spec.ts new file mode 100644 index 00000000000..c69a9108f99 --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.spec.ts @@ -0,0 +1,344 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of as observableOf } from 'rxjs'; +import { + QualityAssuranceEventDataService +} from '../../../core/notifications/qa/events/quality-assurance-event-data.service'; +import { QualityAssuranceEventsComponent } from './quality-assurance-events.component'; +import { + getMockQualityAssuranceEventRestService, + ItemMockPid10, + ItemMockPid8, + ItemMockPid9, + NotificationsMockDspaceObject, + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound +} from '../../../shared/mocks/notifications.mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { + QualityAssuranceEventObject +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceEventData } from '../project-entry-import-modal/project-entry-import-modal.component'; +import { TestScheduler } from 'rxjs/testing'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { ItemDataService } from 'src/app/core/data/item-data.service'; +import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service'; + +describe('QualityAssuranceEventsComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceEventsComponent; + let compAsAny: any; + let scheduler: TestScheduler; + + const modalStub = { + open: () => ( {result: new Promise((res, rej) => 'do')} ), + close: () => null, + dismiss: () => null + }; + const qualityAssuranceEventRestServiceStub: any = getMockQualityAssuranceEventRestService(); + const activatedRouteParams = { + qualityAssuranceEventsParams: { + currentPage: 0, + pageSize: 10 + } + }; + const activatedRouteParamsMap = { + id: 'ENRICH!MISSING!PROJECT' + }; + + const events: QualityAssuranceEventObject[] = [ + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound + ]; + const paginationService = new PaginationServiceStub(); + + function getQualityAssuranceEventData1(): QualityAssuranceEventData { + return { + event: qualityAssuranceEventObjectMissingProjectFound, + id: qualityAssuranceEventObjectMissingProjectFound.id, + title: qualityAssuranceEventObjectMissingProjectFound.title, + hasProject: true, + projectTitle: qualityAssuranceEventObjectMissingProjectFound.message.title, + projectId: ItemMockPid10.id, + handle: ItemMockPid10.handle, + reason: null, + isRunning: false, + target: ItemMockPid8 + }; + } + + function getQualityAssuranceEventData2(): QualityAssuranceEventData { + return { + event: qualityAssuranceEventObjectMissingProjectNotFound, + id: qualityAssuranceEventObjectMissingProjectNotFound.id, + title: qualityAssuranceEventObjectMissingProjectNotFound.title, + hasProject: false, + projectTitle: null, + projectId: null, + handle: null, + reason: null, + isRunning: false, + target: ItemMockPid9 + }; + } + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceEventsComponent, + TestComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub(activatedRouteParamsMap, activatedRouteParams) }, + { provide: QualityAssuranceEventDataService, useValue: qualityAssuranceEventRestServiceStub }, + { provide: NgbModal, useValue: modalStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: PaginationService, useValue: paginationService }, + { provide: ItemDataService, useValue: {} }, + { provide: AuthorizationDataService, useValue: {} }, + QualityAssuranceEventsComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + scheduler = getTestScheduler(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceEventsComponent', inject([QualityAssuranceEventsComponent], (app: QualityAssuranceEventsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceEventsComponent); + comp = fixture.componentInstance; + compAsAny = comp; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + describe('fetchEvents', () => { + it('should fetch events', () => { + const result = compAsAny.fetchEvents(events); + const expected = cold('(a|)', { + a: [ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('modalChoice', () => { + beforeEach(() => { + spyOn(comp, 'executeAction'); + spyOn(comp, 'openModal'); + }); + + it('should call executeAction if a project is present', () => { + const action = 'ACCEPTED'; + comp.modalChoice(action, getQualityAssuranceEventData1(), modalStub); + expect(comp.executeAction).toHaveBeenCalledWith(action, getQualityAssuranceEventData1()); + }); + + it('should call openModal if a project is not present', () => { + const action = 'ACCEPTED'; + comp.modalChoice(action, getQualityAssuranceEventData2(), modalStub); + expect(comp.openModal).toHaveBeenCalledWith(action, getQualityAssuranceEventData2(), modalStub); + }); + }); + + describe('openModal', () => { + it('should call modalService.open', () => { + const action = 'ACCEPTED'; + comp.selectedReason = null; + spyOn(compAsAny.modalService, 'open').and.returnValue({ result: new Promise((res, rej) => 'do' ) }); + spyOn(comp, 'executeAction'); + + comp.openModal(action, getQualityAssuranceEventData1(), modalStub); + expect(compAsAny.modalService.open).toHaveBeenCalled(); + }); + }); + + describe('openModalLookup', () => { + it('should call modalService.open', () => { + spyOn(comp, 'boundProject'); + spyOn(compAsAny.modalService, 'open').and.returnValue( + { + componentInstance: { + externalSourceEntry: null, + label: null, + importedObject: observableOf({ + indexableObject: NotificationsMockDspaceObject + }) + } + } + ); + scheduler.schedule(() => { + comp.openModalLookup(getQualityAssuranceEventData1()); + }); + scheduler.flush(); + + expect(compAsAny.modalService.open).toHaveBeenCalled(); + expect(compAsAny.boundProject).toHaveBeenCalled(); + }); + }); + + describe('executeAction', () => { + it('should call getQualityAssuranceEvents on 200 response from REST', () => { + const action = 'ACCEPTED'; + spyOn(compAsAny, 'getQualityAssuranceEvents').and.returnValue(observableOf([ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ])); + qualityAssuranceEventRestServiceStub.patchEvent.and.returnValue(createSuccessfulRemoteDataObject$({})); + + scheduler.schedule(() => { + comp.executeAction(action, getQualityAssuranceEventData1()); + }); + scheduler.flush(); + + expect(compAsAny.getQualityAssuranceEvents).toHaveBeenCalled(); + }); + }); + + describe('boundProject', () => { + it('should populate the project data inside "eventData"', () => { + const eventData = getQualityAssuranceEventData2(); + const projectId = 'UUID-23943-34u43-38344'; + const projectName = 'Test Project'; + const projectHandle = '1000/1000'; + qualityAssuranceEventRestServiceStub.boundProject.and.returnValue(createSuccessfulRemoteDataObject$({})); + + scheduler.schedule(() => { + comp.boundProject(eventData, projectId, projectName, projectHandle); + }); + scheduler.flush(); + + expect(eventData.hasProject).toEqual(true); + expect(eventData.projectId).toEqual(projectId); + expect(eventData.projectTitle).toEqual(projectName); + expect(eventData.handle).toEqual(projectHandle); + }); + }); + + describe('removeProject', () => { + it('should remove the project data inside "eventData"', () => { + const eventData = getQualityAssuranceEventData1(); + qualityAssuranceEventRestServiceStub.removeProject.and.returnValue(createNoContentRemoteDataObject$()); + + scheduler.schedule(() => { + comp.removeProject(eventData); + }); + scheduler.flush(); + + expect(eventData.hasProject).toEqual(false); + expect(eventData.projectId).toBeNull(); + expect(eventData.projectTitle).toBeNull(); + expect(eventData.handle).toBeNull(); + }); + }); + + describe('getQualityAssuranceEvents', () => { + it('should call the "qualityAssuranceEventRestService.getEventsByTopic" to take data and "fetchEvents" to populate eventData', () => { + comp.paginationConfig = new PaginationComponentOptions(); + comp.paginationConfig.currentPage = 1; + comp.paginationConfig.pageSize = 20; + comp.paginationSortConfig = new SortOptions('trust', SortDirection.DESC); + comp.topic = activatedRouteParamsMap.id; + const options: FindListOptions = Object.assign(new FindListOptions(), { + currentPage: comp.paginationConfig.currentPage, + elementsPerPage: comp.paginationConfig.pageSize + }); + + const pageInfo = new PageInfo({ + elementsPerPage: comp.paginationConfig.pageSize, + totalElements: 2, + totalPages: 1, + currentPage: comp.paginationConfig.currentPage + }); + const array = [ + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound, + ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + qualityAssuranceEventRestServiceStub.getEventsByTopic.and.returnValue(observableOf(paginatedListRD)); + spyOn(compAsAny, 'fetchEvents').and.returnValue(observableOf([ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ])); + + scheduler.schedule(() => { + compAsAny.getQualityAssuranceEvents().subscribe(); + }); + scheduler.flush(); + + expect(compAsAny.qualityAssuranceEventRestService.getEventsByTopic).toHaveBeenCalledWith( + activatedRouteParamsMap.id, + options, + followLink('target'),followLink('related') + ); + expect(compAsAny.fetchEvents).toHaveBeenCalled(); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.ts b/src/app/notifications/qa/events/quality-assurance-events.component.ts new file mode 100644 index 00000000000..953fc9c4053 --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.ts @@ -0,0 +1,516 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest, from, Observable, of, Subscription } from 'rxjs'; +import { distinctUntilChanged, last, map, mergeMap, scan, switchMap, take, tap } from 'rxjs/operators'; + +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { + SourceQualityAssuranceEventMessageObject, + QualityAssuranceEventObject +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { + QualityAssuranceEventDataService +} from '../../../core/notifications/qa/events/quality-assurance-event-data.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { hasValue } from '../../../shared/empty.util'; +import { ItemSearchResult } from '../../../shared/object-collection/shared/item-search-result.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + ProjectEntryImportModalComponent, + QualityAssuranceEventData +} from '../project-entry-import-modal/project-entry-import-modal.component'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { Item } from '../../../core/shared/item.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { environment } from '../../../../environments/environment'; +import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; +import { ItemDataService } from '../../../core/data/item-data.service'; + +/** + * Component to display the Quality Assurance event list. + */ +@Component({ + selector: 'ds-quality-assurance-events', + templateUrl: './quality-assurance-events.component.html', + styleUrls: ['./quality-assurance-events.component.scss'], +}) +export class QualityAssuranceEventsComponent implements OnInit, OnDestroy { + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'bep', + currentPage: 1, + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance event list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions = new SortOptions('trust', SortDirection.DESC); + /** + * Array to save the presence of a project inside an Quality Assurance event. + * @type {QualityAssuranceEventData[]>} + */ + public eventsUpdated$: BehaviorSubject = new BehaviorSubject([]); + /** + * The total number of Quality Assurance events. + * @type {Observable} + */ + public totalElements$: BehaviorSubject = new BehaviorSubject(null); + /** + * The topic of the Quality Assurance events; suitable for displaying. + * @type {string} + */ + public showTopic: string; + /** + * The topic of the Quality Assurance events; suitable for HTTP calls. + * @type {string} + */ + public topic: string; + /** + * The sourceId of the Quality Assurance events. + * @type {string} + */ + sourceId: string; + /** + * The rejected/ignore reason. + * @type {string} + */ + public selectedReason: string; + /** + * Contains the information about the loading status of the page. + * @type {Observable} + */ + public isEventPageLoading: BehaviorSubject = new BehaviorSubject(false); + + /** + * The modal reference. + * @type {any} + */ + public modalRef: any; + /** + * Used to store the status of the 'Show more' button of the abstracts. + * @type {boolean} + */ + public showMore = false; + /** + * The quality assurance source base url for project search + */ + public sourceUrlForProjectSearch: string; + /** + * The FindListOptions object + */ + protected defaultConfig: FindListOptions = Object.assign(new FindListOptions(), { sort: this.paginationSortConfig }); + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * The target item id, retrieved from the topic-id composition. + */ + public targetId: string; + + /** + * The URL of the item page/target. + */ + public itemPageUrl: string; + + /** + * Plain topic name (without the source id) + */ + public selectedTopicName: string; + + + /** + * Observable that emits a boolean value indicating whether the user is an admin. + */ + isAdmin$: Observable; + + /** + * Initialize the component variables. + * @param {ActivatedRoute} activatedRoute + * @param {NgbModal} modalService + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceEventDataService} qualityAssuranceEventRestService + * @param {PaginationService} paginationService + * @param {TranslateService} translateService + * @param authorizationService + * @param itemService + */ + constructor( + private activatedRoute: ActivatedRoute, + private modalService: NgbModal, + private notificationsService: NotificationsService, + private qualityAssuranceEventRestService: QualityAssuranceEventDataService, + private paginationService: PaginationService, + private translateService: TranslateService, + private authorizationService: AuthorizationDataService, + private itemService: ItemDataService, + ) { + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.isEventPageLoading.next(true); + this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); + this.activatedRoute.paramMap.pipe( + tap((params) => { + this.sourceUrlForProjectSearch = environment.qualityAssuranceConfig.sourceUrlMapForProjectSearch[params.get('sourceId')]; + this.sourceId = params.get('sourceId'); + }), + map((params) => params.get('topicId')), + take(1), + switchMap((id: string) => { + const regEx = /!/g; + this.showTopic = id.replace(regEx, '/'); + this.topic = id; + const splitList = this.showTopic?.split(':'); + this.targetId = splitList.length > 2 ? splitList.pop() : null; + this.selectedTopicName = splitList[1]; + this.sourceId = splitList[0]; + return this.getQualityAssuranceEvents(); + }) + ).subscribe( + { + next: (events: QualityAssuranceEventData[]) => { + this.eventsUpdated$.next(events); + this.isEventPageLoading.next(false); + }, + error: (error) => { + this.isEventPageLoading.next(false); + } + } + ); + } + + /** + * Check if table have a detail column + */ + public hasDetailColumn(): boolean { + return (this.showTopic.indexOf('/PROJECT') !== -1 || + this.showTopic.indexOf('/PID') !== -1 || + this.showTopic.indexOf('/SUBJECT') !== -1 || + this.showTopic.indexOf('/WITHDRAWN') !== -1 || + this.showTopic.indexOf('/REINSTATE') !== -1 || + this.showTopic.indexOf('/ABSTRACT') !== -1 + ); + } + + /** + * Open a modal or run the executeAction directly based on the presence of the project. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + * @param {any} content + * Reference to the modal + */ + public modalChoice(action: string, eventData: QualityAssuranceEventData, content: any): void { + if (eventData.hasProject) { + this.executeAction(action, eventData); + } else { + this.openModal(action, eventData, content); + } + } + + /** + * Open the selected modal and performs the action if needed. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + * @param {any} content + * Reference to the modal + */ + public openModal(action: string, eventData: QualityAssuranceEventData, content: any): void { + this.modalService.open(content, { ariaLabelledBy: 'modal-basic-title' }).result.then( + (result) => { + if (result === 'do') { + eventData.reason = this.selectedReason; + this.executeAction(action, eventData); + } + this.selectedReason = null; + }, + (_reason) => { + this.selectedReason = null; + } + ); + } + + /** + * Open a modal where the user can select the project. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event item data + */ + public openModalLookup(eventData: QualityAssuranceEventData): void { + this.modalRef = this.modalService.open(ProjectEntryImportModalComponent, { + size: 'lg' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.externalSourceEntry = eventData; + modalComp.label = 'project'; + this.subs.push( + modalComp.importedObject.pipe(take(1)) + .subscribe((object: ItemSearchResult) => { + const projectTitle = Metadata.first(object.indexableObject.metadata, 'dc.title'); + this.boundProject( + eventData, + object.indexableObject.id, + projectTitle.value, + object.indexableObject.handle + ); + }) + ); + } + + /** + * Performs the choosen action calling the REST service. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + */ + public executeAction(action: string, eventData: QualityAssuranceEventData): void { + eventData.isRunning = true; + let operation; + if (action === 'UNDO') { + operation = this.delete(eventData); + } else { + operation = this.qualityAssuranceEventRestService.patchEvent(action, eventData.event, eventData.reason); + } + this.subs.push( + operation.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.action.saved') + ); + return this.getQualityAssuranceEvents(); + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.action.error') + ); + return of(this.eventsUpdated$.value); + } + }) + ).subscribe((events: QualityAssuranceEventData[]) => { + this.eventsUpdated$.next(events); + eventData.isRunning = false; + }) + ); + } + + /** + * Bound a project to the publication described in the Quality Assurance event calling the REST service. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event item data + * @param {string} projectId + * the project Id to bound + * @param {string} projectTitle + * the project title + * @param {string} projectHandle + * the project handle + */ + public boundProject(eventData: QualityAssuranceEventData, projectId: string, projectTitle: string, projectHandle: string): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.boundProject(eventData.id, projectId).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.project.bounded') + ); + eventData.hasProject = true; + eventData.projectTitle = projectTitle; + eventData.handle = projectHandle; + eventData.projectId = projectId; + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.project.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Remove the bounded project from the publication described in the Quality Assurance event calling the REST service. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + */ + public removeProject(eventData: QualityAssuranceEventData): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.removeProject(eventData.id).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.project.removed') + ); + eventData.hasProject = false; + eventData.projectTitle = null; + eventData.handle = null; + eventData.projectId = null; + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.project.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Check if the event has a valid href. + * @param event + */ + public hasPIDHref(event: SourceQualityAssuranceEventMessageObject): boolean { + return this.getPIDHref(event) !== null; + } + + /** + * Get the event pid href. + * @param event + */ + public getPIDHref(event: SourceQualityAssuranceEventMessageObject): string { + return event.pidHref; + } + + /** + * Dispatch the Quality Assurance events retrival. + */ + public getQualityAssuranceEvents(): Observable { + return this.paginationService.getFindListOptions(this.paginationConfig.id, this.defaultConfig).pipe( + distinctUntilChanged(), + switchMap((options: FindListOptions) => this.qualityAssuranceEventRestService.getEventsByTopic( + this.topic, + options, + followLink('target'), followLink('related') + )), + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData>) => { + if (rd.hasSucceeded) { + this.totalElements$.next(rd.payload.totalElements); + if (rd.payload?.page?.length > 0) { + return this.fetchEvents(rd.payload.page); + } else { + return of([]); + } + } else { + throw new Error('Can\'t retrieve Quality Assurance events from the Broker events REST service'); + } + }), + take(1), + tap(() => { + this.qualityAssuranceEventRestService.clearFindByTopicRequests(); + }) + ); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + + /** + * Fetch Quality Assurance events in order to build proper QualityAssuranceEventData object. + * + * @param {QualityAssuranceEventObject[]} events + * the Quality Assurance event item + * @return array of QualityAssuranceEventData + */ + protected fetchEvents(events: QualityAssuranceEventObject[]): Observable { + return from(events).pipe( + mergeMap((event: QualityAssuranceEventObject) => { + const related$ = event.related.pipe( + getFirstCompletedRemoteData(), + ); + const target$ = event.target.pipe( + getFirstCompletedRemoteData() + ); + return combineLatest([related$, target$]).pipe( + map(([relatedItemRD, targetItemRD]: [RemoteData, RemoteData]) => { + const data: QualityAssuranceEventData = { + event: event, + id: event.id, + title: event.title, + hasProject: false, + projectTitle: null, + projectId: null, + handle: null, + reason: null, + isRunning: false, + target: (targetItemRD?.hasSucceeded) ? targetItemRD.payload : null, + }; + if (relatedItemRD?.hasSucceeded && relatedItemRD?.payload?.id) { + data.hasProject = true; + data.projectTitle = event.message.title; + data.projectId = relatedItemRD?.payload?.id; + data.handle = relatedItemRD?.payload?.handle; + } + return data; + }) + ); + }), + scan((acc: any, value: any) => [...acc, value], []), + last() + ); + } + + /** + * Deletes a quality assurance event. + * @param qaEvent The quality assurance event to delete. + * @returns An Observable of RemoteData containing NoContent. + */ + delete(qaEvent: QualityAssuranceEventData): Observable> { + return this.qualityAssuranceEventRestService.deleteQAEvent(qaEvent); + } + + /** + * Returns an Observable that emits the title of the target item. + * The target item is retrieved by its ID using the itemService. + * The title is extracted from the first metadata value of the item. + * The item page URL is also set in the component. + * @returns An Observable that emits the title of the target item. + */ + public getTargetItemTitle(): Observable { + return this.itemService.findById(this.targetId).pipe( + take(1), + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + tap((item: Item) => this.itemPageUrl = getItemPageRoute(item)), + map((item: Item) => item.firstMetadataValue('dc.title')) + ); + } +} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html new file mode 100644 index 00000000000..0622e08ab09 --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html @@ -0,0 +1,71 @@ + + + diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss new file mode 100644 index 00000000000..7db9839e384 --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts new file mode 100644 index 00000000000..42a57c2ac5e --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts @@ -0,0 +1,210 @@ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { Item } from '../../../core/shared/item.model'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ImportType, ProjectEntryImportModalComponent } from './project-entry-import-modal.component'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockSearchService } from '../../../shared/mocks/search-service.mock'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + ItemMockPid10, + qualityAssuranceEventObjectMissingProjectFound, + NotificationsMockDspaceObject +} from '../../../shared/mocks/notifications.mock'; + +const eventData = { + event: qualityAssuranceEventObjectMissingProjectFound, + id: qualityAssuranceEventObjectMissingProjectFound.id, + title: qualityAssuranceEventObjectMissingProjectFound.title, + hasProject: true, + projectTitle: qualityAssuranceEventObjectMissingProjectFound.message.title, + projectId: ItemMockPid10.id, + handle: ItemMockPid10.handle, + reason: null, + isRunning: false +}; + +const searchString = 'Test project to search'; +const pagination = Object.assign( + new PaginationComponentOptions(), { + id: 'notifications-project-bound', + pageSize: 3 + } +); +const searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: 'funding', + query: searchString, + pagination: pagination + } +)); +const pageInfo = new PageInfo({ + elementsPerPage: 3, + totalElements: 1, + totalPages: 1, + currentPage: 1 +}); +const array = [ + NotificationsMockDspaceObject, +]; +const paginatedList = buildPaginatedList(pageInfo, array); +const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + +describe('ProjectEntryImportModalComponent test suite', () => { + let fixture: ComponentFixture; + let comp: ProjectEntryImportModalComponent; + let compAsAny: any; + + const modalStub = jasmine.createSpyObj('modal', ['close', 'dismiss']); + const uuid = '123e4567-e89b-12d3-a456-426614174003'; + const searchServiceStub: any = getMockSearchService(); + + + beforeEach(async (() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + ProjectEntryImportModalComponent, + TestComponent, + ], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { provide: SearchService, useValue: searchServiceStub }, + { provide: SelectableListService, useValue: jasmine.createSpyObj('selectableListService', ['deselect', 'select', 'deselectAll']) }, + ProjectEntryImportModalComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + searchServiceStub.search.and.returnValue(observableOf(paginatedListRD)); + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ProjectEntryImportModalComponent', inject([ProjectEntryImportModalComponent], (app: ProjectEntryImportModalComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ProjectEntryImportModalComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + describe('close', () => { + it('should close the modal', () => { + comp.close(); + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('search', () => { + it('should call SearchService.search', () => { + + (searchServiceStub as any).search.and.returnValue(observableOf(paginatedListRD)); + comp.pagination = pagination; + + comp.search(searchString); + expect(comp.searchService.search).toHaveBeenCalledWith(searchOptions); + }); + }); + + describe('bound', () => { + it('should call close, deselectAllLists and importedObject.emit', () => { + spyOn(comp, 'deselectAllLists'); + spyOn(comp, 'close'); + spyOn(comp.importedObject, 'emit'); + comp.selectedEntity = NotificationsMockDspaceObject; + comp.bound(); + + expect(comp.importedObject.emit).toHaveBeenCalled(); + expect(comp.deselectAllLists).toHaveBeenCalled(); + expect(comp.close).toHaveBeenCalled(); + }); + }); + + describe('selectEntity', () => { + const entity = Object.assign(new Item(), { uuid: uuid }); + beforeEach(() => { + comp.selectEntity(entity); + }); + + it('should set selected entity', () => { + expect(comp.selectedEntity).toBe(entity); + }); + + it('should set the import type to local entity', () => { + expect(comp.selectedImportType).toEqual(ImportType.LocalEntity); + }); + }); + + describe('deselectEntity', () => { + const entity = Object.assign(new Item(), { uuid: uuid }); + beforeEach(() => { + comp.selectedImportType = ImportType.LocalEntity; + comp.selectedEntity = entity; + comp.deselectEntity(); + }); + + it('should remove the selected entity', () => { + expect(comp.selectedEntity).toBeUndefined(); + }); + + it('should set the import type to none', () => { + expect(comp.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('deselectAllLists', () => { + it('should call SelectableListService.deselectAll', () => { + comp.deselectAllLists(); + expect(compAsAny.selectService.deselectAll).toHaveBeenCalledWith(comp.entityListId); + expect(compAsAny.selectService.deselectAll).toHaveBeenCalledWith(comp.authorityListId); + }); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + eventData = eventData; +} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts new file mode 100644 index 00000000000..ad9c1035a51 --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts @@ -0,0 +1,278 @@ +import { Component, EventEmitter, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { CollectionElementLinkType } from '../../../shared/object-collection/collection-element-link.type'; +import { Context } from '../../../core/shared/context.model'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { + SourceQualityAssuranceEventMessageObject, + QualityAssuranceEventObject, +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { Item } from '../../../core/shared/item.model'; + +/** + * The possible types of import for the external entry + */ +export enum ImportType { + None = 'None', + LocalEntity = 'LocalEntity', + LocalAuthority = 'LocalAuthority', + NewEntity = 'NewEntity', + NewAuthority = 'NewAuthority' +} + +/** + * The data type passed from the parent page + */ +export interface QualityAssuranceEventData { + /** + * The Quality Assurance event + */ + event: QualityAssuranceEventObject; + /** + * The Quality Assurance event Id (uuid) + */ + id: string; + /** + * The publication title + */ + title: string; + /** + * Contains the boolean that indicates if a project is present + */ + hasProject: boolean; + /** + * The project title, if present + */ + projectTitle: string; + /** + * The project id (uuid), if present + */ + projectId: string; + /** + * The project handle, if present + */ + handle: string; + /** + * The reject/discard reason + */ + reason: string; + /** + * Contains the boolean that indicates if there is a running operation (REST call) + */ + isRunning: boolean; + /** + * The related publication DSpace item + */ + target?: Item; +} + +@Component({ + selector: 'ds-project-entry-import-modal', + styleUrls: ['./project-entry-import-modal.component.scss'], + templateUrl: './project-entry-import-modal.component.html' +}) +/** + * Component to display a modal window for linking a project to an Quality Assurance event + * Shows information about the selected project and a selectable list. + */ +export class ProjectEntryImportModalComponent implements OnInit { + /** + * The external source entry + */ + @Input() externalSourceEntry: QualityAssuranceEventData; + /** + * The number of results per page + */ + pageSize = 3; + /** + * The prefix for every i18n key within this modal + */ + labelPrefix = 'quality-assurance.event.modal.'; + /** + * The search configuration to retrieve project + */ + configuration = 'funding'; + /** + * The label to use for all messages (added to the end of relevant i18n keys) + */ + label: string; + /** + * The project title from the parent object + */ + projectTitle: string; + /** + * The search results + */ + localEntitiesRD$: Observable>>>; + /** + * Information about the data loading status + */ + isLoading$ = observableOf(true); + /** + * Search options to use for fetching projects + */ + searchOptions: PaginatedSearchOptions; + /** + * The context we're currently in (submission) + */ + context = Context.EntitySearchModalWithNameVariants; + /** + * List ID for selecting local entities + */ + entityListId = 'notifications-project-bound'; + /** + * List ID for selecting local authorities + */ + authorityListId = 'notifications-project-bound-authority'; + /** + * ImportType enum + */ + importType = ImportType; + /** + * The type of link to render in listable elements + */ + linkTypes = CollectionElementLinkType; + /** + * The type of import the user currently has selected + */ + selectedImportType = ImportType.None; + /** + * The selected local entity + */ + selectedEntity: ListableObject; + /** + * An project has been selected, send it to the parent component + */ + importedObject: EventEmitter = new EventEmitter(); + /** + * Pagination options + */ + pagination: PaginationComponentOptions; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {NgbActiveModal} modal + * @param {SearchService} searchService + * @param {SelectableListService} selectService + */ + constructor(public modal: NgbActiveModal, + public searchService: SearchService, + private selectService: SelectableListService) { } + + /** + * Component intitialization. + */ + public ngOnInit(): void { + this.pagination = Object.assign(new PaginationComponentOptions(), { id: 'notifications-project-bound', pageSize: this.pageSize }); + this.projectTitle = (this.externalSourceEntry.projectTitle !== null) ? this.externalSourceEntry.projectTitle + : (this.externalSourceEntry.event.message as SourceQualityAssuranceEventMessageObject).title; + this.searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: this.configuration, + query: this.projectTitle, + pagination: this.pagination + } + )); + this.localEntitiesRD$ = this.searchService.search(this.searchOptions); + this.subs.push( + this.localEntitiesRD$.subscribe( + () => this.isLoading$ = observableOf(false) + ) + ); + } + + /** + * Close the modal. + */ + public close(): void { + this.deselectAllLists(); + this.modal.close(); + } + + /** + * Perform a project search by title. + */ + public search(searchTitle): void { + if (isNotEmpty(searchTitle)) { + const filterRegEx = /[:]/g; + this.isLoading$ = observableOf(true); + this.searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: this.configuration, + query: (searchTitle) ? searchTitle.replace(filterRegEx, '') : searchTitle, + pagination: this.pagination + } + )); + this.localEntitiesRD$ = this.searchService.search(this.searchOptions); + this.subs.push( + this.localEntitiesRD$.subscribe( + () => this.isLoading$ = observableOf(false) + ) + ); + } + } + + /** + * Perform the bound of the project. + */ + public bound(): void { + if (this.selectedEntity !== undefined) { + this.importedObject.emit(this.selectedEntity); + } + this.selectedImportType = ImportType.None; + this.deselectAllLists(); + this.close(); + } + + /** + * Deselected a local entity + */ + public deselectEntity(): void { + this.selectedEntity = undefined; + if (this.selectedImportType === ImportType.LocalEntity) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local entity + * @param entity + */ + public selectEntity(entity): void { + this.selectedEntity = entity; + this.selectedImportType = ImportType.LocalEntity; + } + + /** + * Deselect every element from both entity and authority lists + */ + public deselectAllLists(): void { + this.selectService.deselectAll(this.entityListId); + this.selectService.deselectAll(this.authorityListId); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.deselectAllLists(); + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.actions.ts b/src/app/notifications/qa/source/quality-assurance-source.actions.ts new file mode 100644 index 00000000000..f6d9c19eaaf --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.actions.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const QualityAssuranceSourceActionTypes = { + ADD_SOURCE: type('dspace/integration/notifications/qa/ADD_SOURCE'), + RETRIEVE_ALL_SOURCE: type('dspace/integration/notifications/qa/RETRIEVE_ALL_SOURCE'), + RETRIEVE_ALL_SOURCE_ERROR: type('dspace/integration/notifications/qa/RETRIEVE_ALL_SOURCE_ERROR'), +}; + +/** + * An ngrx action to retrieve all the Quality Assurance source. + */ +export class RetrieveAllSourceAction implements Action { + type = QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE; + payload: { + elementsPerPage: number; + currentPage: number; + }; + + /** + * Create a new RetrieveAllSourceAction. + * + * @param elementsPerPage + * the number of source per page + * @param currentPage + * The page number to retrieve + */ + constructor(elementsPerPage: number, currentPage: number) { + this.payload = { + elementsPerPage, + currentPage + }; + } +} + +/** + * An ngrx action for retrieving 'all Quality Assurance source' error. + */ +export class RetrieveAllSourceErrorAction implements Action { + type = QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR; +} + +/** + * An ngrx action to load the Quality Assurance source objects. + * Called by the ??? effect. + */ +export class AddSourceAction implements Action { + type = QualityAssuranceSourceActionTypes.ADD_SOURCE; + payload: { + source: QualityAssuranceSourceObject[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddSourceAction. + * + * @param source + * the list of source + * @param totalPages + * the total available pages of source + * @param currentPage + * the current page + * @param totalElements + * the total available Quality Assurance source + */ + constructor(source: QualityAssuranceSourceObject[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + source, + totalPages, + currentPage, + totalElements + }; + } + +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type QualityAssuranceSourceActions + = RetrieveAllSourceAction + |RetrieveAllSourceErrorAction + |AddSourceAction; diff --git a/src/app/notifications/qa/source/quality-assurance-source.component.html b/src/app/notifications/qa/source/quality-assurance-source.component.html new file mode 100644 index 00000000000..543304aacc4 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.component.html @@ -0,0 +1,58 @@ +
+
+
+

{{'quality-assurance.title'| translate}}

+ +
+
+
+
+

{{'quality-assurance.source'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + + + +
{{'quality-assurance.table.source' | translate}}{{'quality-assurance.table.last-event' | translate}}{{'quality-assurance.table.actions' | translate}}
{{sourceElement.id}}{{sourceElement.lastEvent | date: 'dd/MM/yyyy hh:mm' }} +
+ +
+
+
+
+
+
+
+
+ diff --git a/src/app/notifications/qa/source/quality-assurance-source.component.spec.ts b/src/app/notifications/qa/source/quality-assurance-source.component.spec.ts new file mode 100644 index 00000000000..ba3a903cc5e --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.component.spec.ts @@ -0,0 +1,152 @@ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + getMockNotificationsStateService, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { QualityAssuranceSourceComponent } from './quality-assurance-source.component'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { cold } from 'jasmine-marbles'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; + +describe('QualityAssuranceSourceComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceSourceComponent; + let compAsAny: any; + const mockNotificationsStateService = getMockNotificationsStateService(); + const activatedRouteParams = { + qualityAssuranceSourceParams: { + currentPage: 0, + pageSize: 5 + } + }; + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceSourceComponent, + TestComponent, + ], + providers: [ + { provide: NotificationsStateService, useValue: mockNotificationsStateService }, + { provide: ActivatedRoute, useValue: { data: observableOf(activatedRouteParams), params: observableOf({}) } }, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceSourceComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(() => { + mockNotificationsStateService.getQualityAssuranceSource.and.returnValue(observableOf([ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract + ])); + mockNotificationsStateService.getQualityAssuranceSourceTotalPages.and.returnValue(observableOf(1)); + mockNotificationsStateService.getQualityAssuranceSourceCurrentPage.and.returnValue(observableOf(0)); + mockNotificationsStateService.getQualityAssuranceSourceTotals.and.returnValue(observableOf(2)); + mockNotificationsStateService.isQualityAssuranceSourceLoaded.and.returnValue(observableOf(true)); + mockNotificationsStateService.isQualityAssuranceSourceLoading.and.returnValue(observableOf(false)); + mockNotificationsStateService.isQualityAssuranceSourceProcessing.and.returnValue(observableOf(false)); + }); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceSourceComponent', inject([QualityAssuranceSourceComponent], (app: QualityAssuranceSourceComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests running with two Source', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceSourceComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it(('Should init component properly'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + expect(comp.sources$).toBeObservable(cold('(a|)', { + a: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract + ] + })); + expect(comp.totalElements$).toBeObservable(cold('(a|)', { + a: 2 + })); + }); + + it(('Should set data properly after the view init'), () => { + spyOn(compAsAny, 'getQualityAssuranceSource'); + + comp.ngAfterViewInit(); + fixture.detectChanges(); + + expect(compAsAny.getQualityAssuranceSource).toHaveBeenCalled(); + }); + + it(('isSourceLoading should return FALSE'), () => { + expect(comp.isSourceLoading()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('isSourceProcessing should return FALSE'), () => { + expect(comp.isSourceProcessing()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('getQualityAssuranceSource should call the service to dispatch a STATE change'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceSource(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage).and.callThrough(); + expect(compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceSource).toHaveBeenCalledWith(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.component.ts b/src/app/notifications/qa/source/quality-assurance-source.component.ts new file mode 100644 index 00000000000..2a119ca2e34 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.component.ts @@ -0,0 +1,141 @@ +import { Component, OnInit } from '@angular/core'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, take } from 'rxjs/operators'; +import { SortOptions } from '../../../core/cache/models/sort-options.model'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { hasValue } from '../../../shared/empty.util'; +import { QualityAssuranceSourcePageParams } from '../../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service'; + +/** + * Component to display the Quality Assurance source list. + */ +@Component({ + selector: 'ds-quality-assurance-source', + templateUrl: './quality-assurance-source.component.html' +}) +export class QualityAssuranceSourceComponent implements OnInit { + + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'btp', + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance source list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions; + /** + * The Quality Assurance source list. + */ + public sources$: Observable; + /** + * The total number of Quality Assurance sources. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {NotificationsStateService} notificationsStateService + */ + constructor( + private paginationService: PaginationService, + private notificationsStateService: NotificationsStateService, + ) { } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.sources$ = this.notificationsStateService.getQualityAssuranceSource(); + this.totalElements$ = this.notificationsStateService.getQualityAssuranceSourceTotals(); + } + + /** + * First Quality Assurance source loading after view initialization. + */ + ngAfterViewInit(): void { + this.subs.push( + this.notificationsStateService.isQualityAssuranceSourceLoaded().pipe( + take(1) + ).subscribe(() => { + this.getQualityAssuranceSource(); + }) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if the source are loading, 'false' otherwise. + */ + public isSourceLoading(): Observable { + return this.notificationsStateService.isQualityAssuranceSourceLoading(); + } + + /** + * Returns the information about the processing status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the source (ex.: a REST call), 'false' otherwise. + */ + public isSourceProcessing(): Observable { + return this.notificationsStateService.isQualityAssuranceSourceProcessing(); + } + + /** + * Dispatch the Quality Assurance source retrival. + */ + public getQualityAssuranceSource(): void { + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + ).subscribe((options: PaginationComponentOptions) => { + this.notificationsStateService.dispatchRetrieveQualityAssuranceSource( + options.pageSize, + options.currentPage + ); + }); + } + + /** + * Update pagination Config from route params + * + * @param eventsRouteParams + */ + protected updatePaginationFromRouteParams(eventsRouteParams: QualityAssuranceSourcePageParams) { + if (eventsRouteParams.currentPage) { + this.paginationConfig.currentPage = eventsRouteParams.currentPage; + } + if (eventsRouteParams.pageSize) { + if (this.paginationConfig.pageSizeOptions.includes(eventsRouteParams.pageSize)) { + this.paginationConfig.pageSize = eventsRouteParams.pageSize; + } else { + this.paginationConfig.pageSize = this.paginationConfig.pageSizeOptions[0]; + } + } + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.effects.ts b/src/app/notifications/qa/source/quality-assurance-source.effects.ts new file mode 100644 index 00000000000..bd85fb18a07 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.effects.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; + +import { + AddSourceAction, + QualityAssuranceSourceActionTypes, + RetrieveAllSourceAction, + RetrieveAllSourceErrorAction, +} from './quality-assurance-source.actions'; +import { + QualityAssuranceSourceObject +} from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceService } from './quality-assurance-source.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + QualityAssuranceSourceDataService +} from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; + +/** + * Provides effect methods for the Quality Assurance source actions. + */ +@Injectable() +export class QualityAssuranceSourceEffects { + + /** + * Retrieve all Quality Assurance source managing pagination and errors. + */ + retrieveAllSource$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [RetrieveAllSourceAction, any]) => { + return this.qualityAssuranceSourceService.getSources( + action.payload.elementsPerPage, + action.payload.currentPage + ).pipe( + map((sources: PaginatedList) => + new AddSourceAction(sources.page, sources.totalPages, sources.currentPage, sources.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return observableOf(new RetrieveAllSourceErrorAction()); + }) + ); + }) + )); + + /** + * Show a notification on error. + */ + retrieveAllSourceErrorAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('quality-assurance.source.error.service.retrieve')); + }) + ), { dispatch: false }); + + /** + * Clear find all source requests from cache. + */ + addSourceAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.ADD_SOURCE), + tap(() => { + this.qualityAssuranceSourceDataService.clearFindAllSourceRequests(); + }) + ), { dispatch: false }); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + * @param {QualityAssuranceSourceDataService} qualityAssuranceSourceDataService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private qualityAssuranceSourceDataService: QualityAssuranceSourceDataService + ) { + } +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts b/src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts new file mode 100644 index 00000000000..fcb717067d5 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts @@ -0,0 +1,68 @@ +import { + AddSourceAction, + RetrieveAllSourceAction, + RetrieveAllSourceErrorAction + } from './quality-assurance-source.actions'; + import { qualityAssuranceSourceReducer, QualityAssuranceSourceState } from './quality-assurance-source.reducer'; + import { + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid + } from '../../../shared/mocks/notifications.mock'; + + describe('qualityAssuranceSourceReducer test suite', () => { + let qualityAssuranceSourceInitialState: QualityAssuranceSourceState; + const elementPerPage = 3; + const currentPage = 0; + + beforeEach(() => { + qualityAssuranceSourceInitialState = { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }; + }); + + it('Action RETRIEVE_ALL_SOURCE should set the State property "processing" to TRUE', () => { + const expectedState = qualityAssuranceSourceInitialState; + expectedState.processing = true; + + const action = new RetrieveAllSourceAction(elementPerPage, currentPage); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action RETRIEVE_ALL_SOURCE_ERROR should change the State to initial State but processing, loaded, and currentPage', () => { + const expectedState = qualityAssuranceSourceInitialState; + expectedState.processing = false; + expectedState.loaded = true; + expectedState.currentPage = 0; + + const action = new RetrieveAllSourceErrorAction(); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action ADD_SOURCE should populate the State with Quality Assurance source', () => { + const expectedState = { + source: [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 0, + totalElements: 2 + }; + + const action = new AddSourceAction( + [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ], + 1, 0, 2 + ); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + }); diff --git a/src/app/notifications/qa/source/quality-assurance-source.reducer.ts b/src/app/notifications/qa/source/quality-assurance-source.reducer.ts new file mode 100644 index 00000000000..08e26a177ac --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.reducer.ts @@ -0,0 +1,72 @@ +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceActionTypes, QualityAssuranceSourceActions } from './quality-assurance-source.actions'; + +/** + * The interface representing the Quality Assurance source state. + */ +export interface QualityAssuranceSourceState { + source: QualityAssuranceSourceObject[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; +} + +/** + * Used for the Quality Assurance source state initialization. + */ +const qualityAssuranceSourceInitialState: QualityAssuranceSourceState = { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 +}; + +/** + * The Quality Assurance Source Reducer + * + * @param state + * the current state initialized with qualityAssuranceSourceInitialState + * @param action + * the action to perform on the state + * @return QualityAssuranceSourceState + * the new state + */ +export function qualityAssuranceSourceReducer(state = qualityAssuranceSourceInitialState, action: QualityAssuranceSourceActions): QualityAssuranceSourceState { + switch (action.type) { + case QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE: { + return Object.assign({}, state, { + source: [], + processing: true + }); + } + + case QualityAssuranceSourceActionTypes.ADD_SOURCE: { + return Object.assign({}, state, { + source: action.payload.source, + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR: { + return Object.assign({}, state, { + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.service.spec.ts b/src/app/notifications/qa/source/quality-assurance-source.service.spec.ts new file mode 100644 index 00000000000..5ce2ed8ee02 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceSourceService } from './quality-assurance-source.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + getMockQualityAssuranceSourceRestService, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { cold } from 'jasmine-marbles'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { + QualityAssuranceSourceDataService +} from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceSourceService', () => { + let service: QualityAssuranceSourceService; + let restService: QualityAssuranceSourceDataService; + let serviceAsAny: any; + let restServiceAsAny: any; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const elementsPerPage = 3; + const currentPage = 0; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: QualityAssuranceSourceDataService, useClass: getMockQualityAssuranceSourceRestService }, + { provide: QualityAssuranceSourceService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + restService = TestBed.inject(QualityAssuranceSourceDataService); + restServiceAsAny = restService; + restServiceAsAny.getSources.and.returnValue(observableOf(paginatedListRD)); + service = new QualityAssuranceSourceService(restService); + serviceAsAny = service; + }); + + describe('getSources', () => { + it('Should proxy the call to qualityAssuranceSourceRestService.getSources', () => { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + const result = service.getSources(elementsPerPage, currentPage); + expect((service as any).qualityAssuranceSourceRestService.getSources).toHaveBeenCalledWith(findListOptions); + }); + + it('Should return a paginated list of Quality Assurance Source', () => { + const expected = cold('(a|)', { + a: paginatedList + }); + const result = service.getSources(elementsPerPage, currentPage); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/notifications/qa/source/quality-assurance-source.service.ts b/src/app/notifications/qa/source/quality-assurance-source.service.ts new file mode 100644 index 00000000000..ea0cb2e5c51 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + QualityAssuranceSourceDataService +} from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { + QualityAssuranceSourceObject +} from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; + +/** + * The service handling all Quality Assurance source requests to the REST service. + */ +@Injectable() +export class QualityAssuranceSourceService { + + /** + * Initialize the service variables. + * @param {QualityAssuranceSourceDataService} qualityAssuranceSourceRestService + */ + constructor( + private qualityAssuranceSourceRestService: QualityAssuranceSourceDataService + ) { + } + + /** + * Return the list of Quality Assurance source managing pagination and errors. + * + * @param elementsPerPage + * The number of the source per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Quality Assurance source. + */ + public getSources(elementsPerPage, currentPage): Observable> { + const sortOptions = new SortOptions('name', SortDirection.ASC); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + + return this.qualityAssuranceSourceRestService.getSources(findListOptions).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Quality Assurance source from the Broker source REST service'); + } + }) + ); + } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.actions.ts b/src/app/notifications/qa/topics/quality-assurance-topics.actions.ts new file mode 100644 index 00000000000..1f42bc0f204 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.actions.ts @@ -0,0 +1,102 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { QualityAssuranceTopicObject } from '../../../core/notifications/qa/models/quality-assurance-topic.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const QualityAssuranceTopicActionTypes = { + ADD_TOPICS: type('dspace/integration/notifications/qa/topic/ADD_TOPICS'), + RETRIEVE_ALL_TOPICS: type('dspace/integration/notifications/qa/topic/RETRIEVE_ALL_TOPICS'), + RETRIEVE_ALL_TOPICS_ERROR: type('dspace/integration/notifications/qa/topic/RETRIEVE_ALL_TOPICS_ERROR'), +}; + +/** + * An ngrx action to retrieve all the Quality Assurance topics. + */ +export class RetrieveAllTopicsAction implements Action { + type = QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS; + payload: { + elementsPerPage: number; + currentPage: number; + source: string; + target?: string; + }; + + /** + * Create a new RetrieveAllTopicsAction. + * + * @param elementsPerPage + * the number of topics per page + * @param currentPage + * The page number to retrieve + */ + constructor(elementsPerPage: number, currentPage: number, source: string, target?: string) { + this.payload = { + elementsPerPage, + currentPage, + source, + target + }; + } +} + +/** + * An ngrx action for retrieving 'all Quality Assurance topics' error. + */ +export class RetrieveAllTopicsErrorAction implements Action { + type = QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR; +} + +/** + * An ngrx action to load the Quality Assurance topic objects. + * Called by the ??? effect. + */ +export class AddTopicsAction implements Action { + type = QualityAssuranceTopicActionTypes.ADD_TOPICS; + payload: { + topics: QualityAssuranceTopicObject[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddTopicsAction. + * + * @param topics + * the list of topics + * @param totalPages + * the total available pages of topics + * @param currentPage + * the current page + * @param totalElements + * the total available Quality Assurance topics + */ + constructor(topics: QualityAssuranceTopicObject[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + topics, + totalPages, + currentPage, + totalElements + }; + } + +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type QualityAssuranceTopicsActions + = AddTopicsAction + |RetrieveAllTopicsAction + |RetrieveAllTopicsErrorAction; diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.component.html b/src/app/notifications/qa/topics/quality-assurance-topics.component.html new file mode 100644 index 00000000000..631296ea1c4 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.component.html @@ -0,0 +1,61 @@ +
+
+
+

{{'quality-assurance.title'| translate}}

+ {{'quality-assurance.topics.description'| translate:{source: sourceId} }} + + {{'quality-assurance.topics.description-with-target'| translate:{source: sourceId} }} + {{(getTargetItemTitle() | async)}} + +
+
+
+
+

{{'quality-assurance.topics'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + + + +
{{'quality-assurance.table.topic' | translate}}{{'quality-assurance.table.last-event' | translate}}{{'quality-assurance.table.actions' | translate}}
{{topicElement.name}}{{topicElement.lastEvent | date: 'dd/MM/yyyy hh:mm' }} +
+ +
+
+
+
+
+
+
+
diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts b/src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts new file mode 100644 index 00000000000..228dbffd5e7 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts @@ -0,0 +1,160 @@ +/* eslint-disable no-empty, @typescript-eslint/no-empty-function */ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + getMockNotificationsStateService, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { QualityAssuranceTopicsComponent } from './quality-assurance-topics.component'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { cold } from 'jasmine-marbles'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { ItemDataService } from 'src/app/core/data/item-data.service'; + +describe('QualityAssuranceTopicsComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceTopicsComponent; + let compAsAny: any; + const mockNotificationsStateService = getMockNotificationsStateService(); + const activatedRouteParams = { + qualityAssuranceTopicsParams: { + currentPage: 0, + pageSize: 5 + } + }; + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceTopicsComponent, + TestComponent, + ], + providers: [ + { provide: NotificationsStateService, useValue: mockNotificationsStateService }, + { provide: ActivatedRoute, useValue: { data: observableOf(activatedRouteParams), snapshot: { + params: { + sourceId: 'openaire', + targetId: null + }, + }}}, + { provide: PaginationService, useValue: paginationService }, + { provide: ItemDataService, useValue: {} }, + QualityAssuranceTopicsComponent, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(() => { + mockNotificationsStateService.getQualityAssuranceTopics.and.returnValue(observableOf([ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract + ])); + mockNotificationsStateService.getQualityAssuranceTopicsTotalPages.and.returnValue(observableOf(1)); + mockNotificationsStateService.getQualityAssuranceTopicsCurrentPage.and.returnValue(observableOf(0)); + mockNotificationsStateService.getQualityAssuranceTopicsTotals.and.returnValue(observableOf(2)); + mockNotificationsStateService.isQualityAssuranceTopicsLoaded.and.returnValue(observableOf(true)); + mockNotificationsStateService.isQualityAssuranceTopicsLoading.and.returnValue(observableOf(false)); + mockNotificationsStateService.isQualityAssuranceTopicsProcessing.and.returnValue(observableOf(false)); + }); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceTopicsComponent', inject([QualityAssuranceTopicsComponent], (app: QualityAssuranceTopicsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests running with two topics', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceTopicsComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it(('Should init component properly'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + expect(comp.topics$).toBeObservable(cold('(a|)', { + a: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract + ] + })); + expect(comp.totalElements$).toBeObservable(cold('(a|)', { + a: 2 + })); + }); + + it(('Should set data properly after the view init'), () => { + spyOn(compAsAny, 'getQualityAssuranceTopics'); + + comp.ngAfterViewInit(); + fixture.detectChanges(); + + expect(compAsAny.getQualityAssuranceTopics).toHaveBeenCalled(); + }); + + it(('isTopicsLoading should return FALSE'), () => { + expect(comp.isTopicsLoading()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('isTopicsProcessing should return FALSE'), () => { + expect(comp.isTopicsProcessing()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('getQualityAssuranceTopics should call the service to dispatch a STATE change'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceTopics(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage).and.callThrough(); + expect(compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceTopics).toHaveBeenCalledWith(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.component.ts b/src/app/notifications/qa/topics/quality-assurance-topics.component.ts new file mode 100644 index 00000000000..7c66a38fa9e --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.component.ts @@ -0,0 +1,222 @@ +import { AfterViewInit, Component, OnDestroy, OnInit } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, map, take, tap } from 'rxjs/operators'; + +import { SortOptions } from '../../../core/cache/models/sort-options.model'; +import { + QualityAssuranceTopicObject +} from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { hasValue } from '../../../shared/empty.util'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { NotificationsStateService } from '../../notifications-state.service'; + +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; +import { getNotificatioQualityAssuranceRoute } from '../../../admin/admin-routing-paths'; +import { + QualityAssuranceTopicsPageParams +} from '../../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service'; + +/** + * Component to display the Quality Assurance topic list. + */ +@Component({ + selector: 'ds-quality-assurance-topic', + templateUrl: './quality-assurance-topics.component.html' +}) +export class QualityAssuranceTopicsComponent implements OnInit, OnDestroy, AfterViewInit { + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'btp', + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance topic list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions; + /** + * The Quality Assurance topic list. + */ + public topics$: Observable; + /** + * The total number of Quality Assurance topics. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * This property represents a sourceId which is used to retrive a topic + * @type {string} + */ + public sourceId: string; + + /** + * This property represents a targetId (item-id) which is used to retrive a topic + * @type {string} + */ + public targetId: string; + + /** + * The URL of the item page. + */ + public itemPageUrl: string; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {ActivatedRoute} activatedRoute + * @param itemService + * @param {NotificationsStateService} notificationsStateService + * @param router + */ + constructor( + private paginationService: PaginationService, + private activatedRoute: ActivatedRoute, + private itemService: ItemDataService, + private notificationsStateService: NotificationsStateService, + private router: Router, + ) { + this.sourceId = this.activatedRoute.snapshot.params.sourceId; + this.targetId = this.activatedRoute.snapshot.params.targetId; + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.topics$ = this.notificationsStateService.getQualityAssuranceTopics().pipe( + tap((topics: QualityAssuranceTopicObject[]) => { + const forward = this.activatedRoute.snapshot.queryParams?.forward === 'true'; + if (topics.length === 1 && forward) { + // If there is only one topic, navigate to the first topic automatically + this.router.navigate([this.getQualityAssuranceRoute(), this.sourceId, topics[0].id]); + } + }) + ); + this.totalElements$ = this.notificationsStateService.getQualityAssuranceTopicsTotals(); + } + + /** + * First Quality Assurance topics loading after view initialization. + */ + ngAfterViewInit(): void { + this.subs.push( + this.notificationsStateService.isQualityAssuranceTopicsLoaded().pipe( + take(1) + ).subscribe(() => { + this.getQualityAssuranceTopics(this.sourceId, this.targetId); + }) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if the topics are loading, 'false' otherwise. + */ + public isTopicsLoading(): Observable { + return this.notificationsStateService.isQualityAssuranceTopicsLoading(); + } + + /** + * Returns the information about the processing status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the topics (ex.: a REST call), 'false' otherwise. + */ + public isTopicsProcessing(): Observable { + return this.notificationsStateService.isQualityAssuranceTopicsProcessing(); + } + + /** + * Dispatch the Quality Assurance topics retrival. + */ + public getQualityAssuranceTopics(source: string, target?: string): void { + this.subs.push(this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + ).subscribe((options: PaginationComponentOptions) => { + this.notificationsStateService.dispatchRetrieveQualityAssuranceTopics( + options.pageSize, + options.currentPage, + source, + target + ); + })); + } + + /** + * Update pagination Config from route params + * + * @param eventsRouteParams + */ + protected updatePaginationFromRouteParams(eventsRouteParams: QualityAssuranceTopicsPageParams) { + if (eventsRouteParams.currentPage) { + this.paginationConfig.currentPage = eventsRouteParams.currentPage; + } + if (eventsRouteParams.pageSize) { + if (this.paginationConfig.pageSizeOptions.includes(eventsRouteParams.pageSize)) { + this.paginationConfig.pageSize = eventsRouteParams.pageSize; + } else { + this.paginationConfig.pageSize = this.paginationConfig.pageSizeOptions[0]; + } + } + } + + /** + * Returns an Observable that emits the title of the target item. + * The target item is retrieved by its ID using the itemService. + * The title is extracted from the first metadata value of the item. + * The item page URL is also set in the component. + * @returns An Observable that emits the title of the target item. + */ + getTargetItemTitle(): Observable { + return this.itemService.findById(this.targetId).pipe( + take(1), + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + tap((item: Item) => this.itemPageUrl = getItemPageRoute(item)), + map((item: Item) => item.firstMetadataValue('dc.title')) + ); + } + + /** + * Returns the page route for the given item. + * @param item The item to get the page route for. + * @returns The page route for the given item. + */ + getItemPageRoute(item: Item): string { + return getItemPageRoute(item); + } + + /** + * Returns the quality assurance route. + * @returns The quality assurance route. + */ + getQualityAssuranceRoute(): string { + return getNotificatioQualityAssuranceRoute(); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.effects.ts b/src/app/notifications/qa/topics/quality-assurance-topics.effects.ts new file mode 100644 index 00000000000..830f10c3238 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.effects.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; + +import { + AddTopicsAction, + QualityAssuranceTopicActionTypes, + RetrieveAllTopicsAction, + RetrieveAllTopicsErrorAction, +} from './quality-assurance-topics.actions'; +import { + QualityAssuranceTopicObject +} from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + QualityAssuranceTopicDataService +} from '../../../core/notifications/qa/topics/quality-assurance-topic-data.service'; + +/** + * Provides effect methods for the Quality Assurance topics actions. + */ +@Injectable() +export class QualityAssuranceTopicsEffects { + + /** + * Retrieve all Quality Assurance topics managing pagination and errors. + */ + retrieveAllTopics$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [RetrieveAllTopicsAction, any]) => { + return this.qualityAssuranceTopicService.getTopics( + action.payload.elementsPerPage, + action.payload.currentPage, + action.payload.source, + action.payload.target + ).pipe( + map((topics: PaginatedList) => + new AddTopicsAction(topics.page, topics.totalPages, topics.currentPage, topics.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return observableOf(new RetrieveAllTopicsErrorAction()); + }) + ); + }) + )); + + /** + * Show a notification on error. + */ + retrieveAllTopicsErrorAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('quality-assurance.topic.error.service.retrieve')); + }) + ), { dispatch: false }); + + /** + * Clear find all topics requests from cache. + */ + addTopicsAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.ADD_TOPICS), + tap(() => { + this.qualityAssuranceTopicDataService.clearFindAllTopicsRequests(); + }) + ), { dispatch: false }); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceTopicsService} qualityAssuranceTopicService + * @param {QualityAssuranceTopicDataService} qualityAssuranceTopicDataService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private qualityAssuranceTopicService: QualityAssuranceTopicsService, + private qualityAssuranceTopicDataService: QualityAssuranceTopicDataService + ) { } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts new file mode 100644 index 00000000000..37d83f6e4fe --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts @@ -0,0 +1,68 @@ +import { + AddTopicsAction, + RetrieveAllTopicsAction, + RetrieveAllTopicsErrorAction +} from './quality-assurance-topics.actions'; +import { qualityAssuranceTopicsReducer, QualityAssuranceTopicState } from './quality-assurance-topics.reducer'; +import { + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; + +describe('qualityAssuranceTopicsReducer test suite', () => { + let qualityAssuranceTopicInitialState: QualityAssuranceTopicState; + const elementPerPage = 3; + const currentPage = 0; + + beforeEach(() => { + qualityAssuranceTopicInitialState = { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }; + }); + + it('Action RETRIEVE_ALL_TOPICS should set the State property "processing" to TRUE', () => { + const expectedState = qualityAssuranceTopicInitialState; + expectedState.processing = true; + + const action = new RetrieveAllTopicsAction(elementPerPage, currentPage, 'ENRICH!MORE!ABSTRACT'); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action RETRIEVE_ALL_TOPICS_ERROR should change the State to initial State but processing, loaded, and currentPage', () => { + const expectedState = qualityAssuranceTopicInitialState; + expectedState.processing = false; + expectedState.loaded = true; + expectedState.currentPage = 0; + + const action = new RetrieveAllTopicsErrorAction(); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action ADD_TOPICS should populate the State with Quality Assurance topics', () => { + const expectedState = { + topics: [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 0, + totalElements: 2 + }; + + const action = new AddTopicsAction( + [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ], + 1, 0, 2 + ); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); +}); diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts new file mode 100644 index 00000000000..ff94f1b8bb1 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts @@ -0,0 +1,72 @@ +import { QualityAssuranceTopicObject } from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceTopicActionTypes, QualityAssuranceTopicsActions } from './quality-assurance-topics.actions'; + +/** + * The interface representing the Quality Assurance topic state. + */ +export interface QualityAssuranceTopicState { + topics: QualityAssuranceTopicObject[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; +} + +/** + * Used for the Quality Assurance topic state initialization. + */ +const qualityAssuranceTopicInitialState: QualityAssuranceTopicState = { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 +}; + +/** + * The Quality Assurance Topic Reducer + * + * @param state + * the current state initialized with qualityAssuranceTopicInitialState + * @param action + * the action to perform on the state + * @return QualityAssuranceTopicState + * the new state + */ +export function qualityAssuranceTopicsReducer(state = qualityAssuranceTopicInitialState, action: QualityAssuranceTopicsActions): QualityAssuranceTopicState { + switch (action.type) { + case QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS: { + return Object.assign({}, state, { + topics: [], + processing: true + }); + } + + case QualityAssuranceTopicActionTypes.ADD_TOPICS: { + return Object.assign({}, state, { + topics: action.payload.topics, + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR: { + return Object.assign({}, state, { + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts b/src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts new file mode 100644 index 00000000000..78cedb2e2a7 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { + QualityAssuranceTopicDataService +} from '../../../core/notifications/qa/topics/quality-assurance-topic-data.service'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + getMockQualityAssuranceTopicRestService, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { cold } from 'jasmine-marbles'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceTopicsService', () => { + let service: QualityAssuranceTopicsService; + let restService: QualityAssuranceTopicDataService; + let serviceAsAny: any; + let restServiceAsAny: any; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const elementsPerPage = 3; + const currentPage = 0; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: QualityAssuranceTopicDataService, useClass: getMockQualityAssuranceTopicRestService }, + { provide: QualityAssuranceTopicsService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + restService = TestBed.inject(QualityAssuranceTopicDataService); + restServiceAsAny = restService; + restServiceAsAny.searchTopicsBySource.and.returnValue(observableOf(paginatedListRD)); + restServiceAsAny.searchTopicsByTarget.and.returnValue(observableOf(paginatedListRD)); + service = new QualityAssuranceTopicsService(restService); + serviceAsAny = service; + }); + + describe('getTopics', () => { + it('should proxy the call to qualityAssuranceTopicRestService.searchTopicsBySource', () => { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions, + searchParams: [new RequestParam('source', 'openaire')] + }; + service.getTopics(elementsPerPage, currentPage, 'openaire'); + expect((service as any).qualityAssuranceTopicRestService.searchTopicsBySource).toHaveBeenCalledWith(findListOptions); + }); + + it('should return a paginated list of Quality Assurance topics', () => { + const expected = cold('(a|)', { + a: paginatedList + }); + const result = service.getTopics(elementsPerPage, currentPage, 'openaire'); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.service.ts b/src/app/notifications/qa/topics/quality-assurance-topics.service.ts new file mode 100644 index 00000000000..131be400ca0 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + QualityAssuranceTopicDataService +} from '../../../core/notifications/qa/topics/quality-assurance-topic-data.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { + QualityAssuranceTopicObject +} from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { hasValue } from '../../../shared/empty.util'; + +/** + * The service handling all Quality Assurance topic requests to the REST service. + */ +@Injectable() +export class QualityAssuranceTopicsService { + + /** + * Initialize the service variables. + * @param {QualityAssuranceTopicDataService} qualityAssuranceTopicRestService + */ + constructor( + private qualityAssuranceTopicRestService: QualityAssuranceTopicDataService + ) { } + + + /** + * Return the list of Quality Assurance topics managing pagination and errors. + * + * @param elementsPerPage + * The number of the topics per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Quality Assurance topics. + */ + public getTopics(elementsPerPage, currentPage, source: string, target?: string): Observable> { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions, + searchParams: [new RequestParam('source', source)] + }; + + let request$: Observable>>; + + if (hasValue(target)) { + findListOptions.searchParams.push(new RequestParam('target', target)); + request$ = this.qualityAssuranceTopicRestService.searchTopicsByTarget(findListOptions); + } else { + request$ = this.qualityAssuranceTopicRestService.searchTopicsBySource(findListOptions); + } + + return request$.pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Quality Assurance topics from the Broker topics REST service'); + } + }) + ); + } +} diff --git a/src/app/notifications/selectors.ts b/src/app/notifications/selectors.ts new file mode 100644 index 00000000000..63b2da7a100 --- /dev/null +++ b/src/app/notifications/selectors.ts @@ -0,0 +1,149 @@ +import { createFeatureSelector, createSelector, MemoizedSelector } from '@ngrx/store'; +import { subStateSelector } from '../shared/selector.util'; +import { suggestionNotificationsSelector, SuggestionNotificationsState } from './notifications.reducer'; +import { QualityAssuranceTopicObject } from '../core/notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceTopicState } from './qa/topics/quality-assurance-topics.reducer'; +import { QualityAssuranceSourceState } from './qa/source/quality-assurance-source.reducer'; +import { + QualityAssuranceSourceObject +} from '../core/notifications/qa/models/quality-assurance-source.model'; + +/** + * Returns the Notifications state. + * @function _getNotificationsState + * @param {AppState} state Top level state. + * @return {SuggestionNotificationsState} + */ +const _getNotificationsState = createFeatureSelector('suggestionNotifications'); + +// Quality Assurance topics +// ---------------------------------------------------------------------------- + +/** + * Returns the Quality Assurance topics State. + * @function qualityAssuranceTopicsStateSelector + * @return {QualityAssuranceTopicState} + */ +export function qualityAssuranceTopicsStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'qaTopic'); +} + +/** + * Returns the Quality Assurance topics list. + * @function qualityAssuranceTopicsObjectSelector + * @return {QualityAssuranceTopicObject[]} + */ +export function qualityAssuranceTopicsObjectSelector(): MemoizedSelector { + return subStateSelector(qualityAssuranceTopicsStateSelector(), 'topics'); +} + +/** + * Returns true if the Quality Assurance topics are loaded. + * @function isQualityAssuranceTopicsLoadedSelector + * @return {boolean} + */ +export const isQualityAssuranceTopicsLoadedSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isQualityAssuranceTopicsProcessingSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.processing +); + +/** + * Returns the total available pages of Quality Assurance topics. + * @function getQualityAssuranceTopicsTotalPagesSelector + * @return {number} + */ +export const getQualityAssuranceTopicsTotalPagesSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.totalPages +); + +/** + * Returns the current page of Quality Assurance topics. + * @function getQualityAssuranceTopicsCurrentPageSelector + * @return {number} + */ +export const getQualityAssuranceTopicsCurrentPageSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.currentPage +); + +/** + * Returns the total number of Quality Assurance topics. + * @function getQualityAssuranceTopicsTotalsSelector + * @return {number} + */ +export const getQualityAssuranceTopicsTotalsSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.totalElements +); + +// Quality Assurance source +// ---------------------------------------------------------------------------- + +/** + * Returns the Quality Assurance source State. + * @function qualityAssuranceSourceStateSelector + * @return {QualityAssuranceSourceState} + */ + export function qualityAssuranceSourceStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'qaSource'); +} + +/** + * Returns the Quality Assurance source list. + * @function qualityAssuranceSourceObjectSelector + * @return {QualityAssuranceSourceObject[]} + */ +export function qualityAssuranceSourceObjectSelector(): MemoizedSelector { + return subStateSelector(qualityAssuranceSourceStateSelector(), 'source'); +} + +/** + * Returns true if the Quality Assurance source are loaded. + * @function isQualityAssuranceSourceLoadedSelector + * @return {boolean} + */ +export const isQualityAssuranceSourceLoadedSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isQualityAssuranceSourceProcessingSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.processing +); + +/** + * Returns the total available pages of Quality Assurance source. + * @function getQualityAssuranceSourceTotalPagesSelector + * @return {number} + */ +export const getQualityAssuranceSourceTotalPagesSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.totalPages +); + +/** + * Returns the current page of Quality Assurance source. + * @function getQualityAssuranceSourceCurrentPageSelector + * @return {number} + */ +export const getQualityAssuranceSourceCurrentPageSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.currentPage +); + +/** + * Returns the total number of Quality Assurance source. + * @function getQualityAssuranceSourceTotalsSelector + * @return {number} + */ +export const getQualityAssuranceSourceTotalsSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.totalElements +); diff --git a/src/app/notifications/suggestion-actions/suggestion-actions.component.html b/src/app/notifications/suggestion-actions/suggestion-actions.component.html new file mode 100644 index 00000000000..2a46191deea --- /dev/null +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.html @@ -0,0 +1,29 @@ +
+
+ + + + + + + +
+ + +
diff --git a/src/app/notifications/suggestion-actions/suggestion-actions.component.scss b/src/app/notifications/suggestion-actions/suggestion-actions.component.scss new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/notifications/suggestion-actions/suggestion-actions.component.ts b/src/app/notifications/suggestion-actions/suggestion-actions.component.ts new file mode 100644 index 00000000000..0929f10603a --- /dev/null +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.ts @@ -0,0 +1,96 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Collection } from '../../core/shared/collection.model'; +import { take } from 'rxjs/operators'; +import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { Suggestion } from '../../core/notifications/models/suggestion.model'; +import { SuggestionApproveAndImport } from '../suggestion-list-element/suggestion-list-element.component'; + +/** + * Show and trigger the actions to submit for a suggestion + */ +@Component({ + selector: 'ds-suggestion-actions', + styleUrls: [ './suggestion-actions.component.scss' ], + templateUrl: './suggestion-actions.component.html' +}) +export class SuggestionActionsComponent { + + @Input() object: Suggestion; + + @Input() isBulk = false; + + @Input() hasEvidence = false; + + @Input() seeEvidence = false; + + @Input() isCollectionFixed = false; + + /** + * The component is used to Delete suggestion + */ + @Output() ignoreSuggestionClicked = new EventEmitter(); + + /** + * The component is used to approve & import + */ + @Output() approveAndImport = new EventEmitter(); + + /** + * The component is used to approve & import + */ + @Output() seeEvidences = new EventEmitter(); + + constructor(private modalService: NgbModal) { } + + /** + * Method called on clicking the button "approve & import", It opens a dialog for + * select a collection and it emits an approveAndImport event. + */ + openDialog(entity: ItemType) { + + const modalRef = this.modalService.open(CreateItemParentSelectorComponent); + modalRef.componentInstance.emitOnly = true; + modalRef.componentInstance.entityType = entity.label; + + modalRef.componentInstance.select?.pipe(take(1)) + .subscribe((collection: Collection) => { + this.approveAndImport.emit({ + suggestion: this.isBulk ? undefined : this.object, + collectionId: collection.id + }); + }); + } + + approveAndImportCollectionFixed() { + this.approveAndImport.emit({ + suggestion: this.isBulk ? undefined : this.object, + collectionId: null + }); + } + + + /** + * Delete the suggestion + */ + ignoreSuggestion() { + this.ignoreSuggestionClicked.emit(this.isBulk ? undefined : this.object.id); + } + + /** + * Toggle See Evidence + */ + toggleSeeEvidences() { + this.seeEvidences.emit(!this.seeEvidence); + } + + ignoreSuggestionLabel(): string { + return this.isBulk ? 'suggestion.ignoreSuggestion.bulk' : 'suggestion.ignoreSuggestion' ; + } + + approveAndImportLabel(): string { + return this.isBulk ? 'suggestion.approveAndImport.bulk' : 'suggestion.approveAndImport'; + } +} diff --git a/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html new file mode 100644 index 00000000000..e23c244eb49 --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html @@ -0,0 +1,20 @@ +
+
+ + + + + + + + + + + + + + + +
{{'suggestion.evidence.score' | translate}}{{'suggestion.evidence.type' | translate}}{{'suggestion.evidence.notes' | translate}}
{{evidences[evidence].score}}{{evidence | translate}}{{evidences[evidence].notes}}
+
+
diff --git a/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts new file mode 100644 index 00000000000..3a4000b1fed --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { fadeIn } from '../../../shared/animations/fade'; +import { SuggestionEvidences } from '../../../core/notifications/models/suggestion.model'; + + +/** + * Show suggestion evidences such as score (authorScore, dateScore) + */ +@Component({ + selector: 'ds-suggestion-evidences', + templateUrl: './suggestion-evidences.component.html', + animations: [fadeIn] +}) +export class SuggestionEvidencesComponent { + + @Input() evidences: SuggestionEvidences; + +} diff --git a/src/app/notifications/suggestion-list-element/suggestion-list-element.component.html b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.html new file mode 100644 index 00000000000..ef27876f2cb --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.html @@ -0,0 +1,45 @@ +
+
+ +
+
+ +
+
+ +
+
+
{{'suggestion.totalScore' | translate}}
+ {{ object.score }} +
+
+ +
+ + + + +
+
+ +
+
+ +
+
+
+
diff --git a/src/app/notifications/suggestion-list-element/suggestion-list-element.component.scss b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.scss new file mode 100644 index 00000000000..1c522095189 --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.scss @@ -0,0 +1,16 @@ +.issue-date { + color: #c8c8c8; +} + +.parent { + display: flex; + gap:10px; +} + +.import { + flex: initial; +} + +.suggestion-score { + font-size: 1.5rem; +} diff --git a/src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts new file mode 100644 index 00000000000..61d1a19996c --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts @@ -0,0 +1,81 @@ +import { SuggestionListElementComponent } from './suggestion-list-element.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { mockSuggestionPublicationOne } from '../../shared/mocks/publication-claim.mock'; +import { Item } from '../../core/shared/item.model'; + + +describe('SuggestionListElementComponent', () => { + let component: SuggestionListElementComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot() + ], + declarations: [SuggestionListElementComponent], + providers: [ + NgbModal + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SuggestionListElementComponent); + component = fixture.componentInstance; + scheduler = getTestScheduler(); + + component.object = mockSuggestionPublicationOne; + }); + + describe('SuggestionListElementComponent test', () => { + + it('should create', () => { + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + const expectedIndexableObject = Object.assign(new Item(), { + id: mockSuggestionPublicationOne.id, + metadata: mockSuggestionPublicationOne.metadata + }); + expect(component).toBeTruthy(); + expect(component.listableObject.hitHighlights).toEqual({}); + expect(component.listableObject.indexableObject).toEqual(expectedIndexableObject); + }); + + it('should check if has evidence', () => { + expect(component.hasEvidences()).toBeTruthy(); + }); + + it('should set seeEvidences', () => { + component.onSeeEvidences(true); + expect(component.seeEvidence).toBeTruthy(); + }); + + it('should emit selection', () => { + spyOn(component.selected, 'next'); + component.changeSelected({target: { checked: true}}); + expect(component.selected.next).toHaveBeenCalledWith(true); + }); + + it('should emit for deletion', () => { + spyOn(component.ignoreSuggestionClicked, 'emit'); + component.onIgnoreSuggestion('1234'); + expect(component.ignoreSuggestionClicked.emit).toHaveBeenCalledWith('1234'); + }); + + it('should emit for approve and import', () => { + const event = {collectionId:'1234', suggestion: mockSuggestionPublicationOne}; + spyOn(component.approveAndImport, 'emit'); + component.onApproveAndImport(event); + expect(component.approveAndImport.emit).toHaveBeenCalledWith(event); + }); + }); +}); diff --git a/src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts new file mode 100644 index 00000000000..bc5db3d1bfb --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts @@ -0,0 +1,105 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { Suggestion } from 'src/app/core/notifications/models/suggestion.model'; +import { fadeIn } from '../../shared/animations/fade'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; + + + +/** + * A simple interface to unite a specific suggestion and the id of the chosen collection + */ +export interface SuggestionApproveAndImport { + suggestion: Suggestion; + collectionId: string; +} + +/** + * Show all the suggestions by researcher + */ +@Component({ + selector: 'ds-suggestion-list-item', + styleUrls: ['./suggestion-list-element.component.scss'], + templateUrl: './suggestion-list-element.component.html', + animations: [fadeIn] +}) +export class SuggestionListElementComponent implements OnInit { + + @Input() object: Suggestion; + + @Input() isSelected = false; + + @Input() isCollectionFixed = false; + + public listableObject: any; + + public seeEvidence = false; + + /** + * The component is used to Delete suggestion + */ + @Output() ignoreSuggestionClicked = new EventEmitter(); + + /** + * The component is used to approve & import + */ + @Output() approveAndImport = new EventEmitter(); + + /** + * New value whether the element is selected + */ + @Output() selected = new EventEmitter(); + + /** + * Initialize instance variables + * + * @param {NgbModal} modalService + */ + constructor(private modalService: NgbModal) { } + + ngOnInit() { + this.listableObject = { + indexableObject: Object.assign(new Item(), {id: this.object.id, metadata: this.object.metadata}), + hitHighlights: {} + }; + } + + /** + * Approve and import the suggestion + */ + onApproveAndImport(event: SuggestionApproveAndImport) { + this.approveAndImport.emit(event); + } + + /** + * Delete the suggestion + */ + onIgnoreSuggestion(suggestionId: string) { + this.ignoreSuggestionClicked.emit(suggestionId); + } + + /** + * Change is selected value. + */ + changeSelected(event) { + this.isSelected = event.target.checked; + this.selected.next(this.isSelected); + } + + /** + * See the Evidence + */ + hasEvidences() { + return isNotEmpty(this.object.evidences); + } + + /** + * Set the see evidence variable. + */ + onSeeEvidences(seeEvidence: boolean) { + this.seeEvidence = seeEvidence; + } + +} diff --git a/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html new file mode 100644 index 00000000000..f2ab1e5b652 --- /dev/null +++ b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html @@ -0,0 +1,51 @@ +
+
+
+

{{'suggestion.title'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + +
{{'suggestion.table.name' | translate}}{{'suggestion.table.actions' | translate}}
+ {{targetElement.display}} + +
+ +
+
+
+
+
+
+
+
diff --git a/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts new file mode 100644 index 00000000000..d1b4c073ef5 --- /dev/null +++ b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts @@ -0,0 +1,143 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, take } from 'rxjs/operators'; + + +import { hasValue } from '../../../shared/empty.util'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { getSuggestionPageRoute } from '../../../suggestions-page/suggestions-page-routing-paths'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { SuggestionTarget } from '../../../core/notifications/models/suggestion-target.model'; +import { SuggestionTargetsStateService } from '../suggestion-targets.state.service'; +import { SuggestionsService } from '../../suggestions.service'; + +/** + * Component to display the Suggestion Target list. + */ +@Component({ + selector: 'ds-publication-claim', + templateUrl: './publication-claim.component.html', +}) +export class PublicationClaimComponent implements OnInit { + + /** + * The source for which to list targets + */ + @Input() source: string; + + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'stp', + pageSizeOptions: [5, 10, 20, 40, 60] + }); + + /** + * The Suggestion Target list. + */ + public targets$: Observable; + /** + * The total number of Suggestion Targets. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {SuggestionTargetsStateService} suggestionTargetsStateService + * @param {SuggestionsService} suggestionService + * @param {Router} router + */ + constructor( + private paginationService: PaginationService, + private suggestionTargetsStateService: SuggestionTargetsStateService, + private suggestionService: SuggestionsService, + private router: Router + ) { + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.targets$ = this.suggestionTargetsStateService.getSuggestionTargets(); + this.totalElements$ = this.suggestionTargetsStateService.getSuggestionTargetsTotals(); + + this.subs.push( + this.suggestionTargetsStateService.isSuggestionTargetsLoaded().pipe( + take(1) + ).subscribe(() => { + this.getSuggestionTargets(); + }) + ); + } + + /** + * Returns the information about the loading status of the Suggestion Targets (if it's running or not). + * + * @return Observable + * 'true' if the targets are loading, 'false' otherwise. + */ + public isTargetsLoading(): Observable { + return this.suggestionTargetsStateService.isSuggestionTargetsLoading(); + } + + /** + * Returns the information about the processing status of the Suggestion Targets (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the targets (ex.: a REST call), 'false' otherwise. + */ + public isTargetsProcessing(): Observable { + return this.suggestionTargetsStateService.isSuggestionTargetsProcessing(); + } + + /** + * Redirect to suggestion page. + * + * @param {string} id + * the id of suggestion target + */ + public redirectToSuggestions(id: string) { + this.router.navigate([getSuggestionPageRoute(id)]); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.suggestionTargetsStateService.dispatchClearSuggestionTargetsAction(); + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + + /** + * Dispatch the Suggestion Targets retrival. + */ + public getSuggestionTargets(): void { + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + take(1) + ).subscribe((options: PaginationComponentOptions) => { + this.suggestionTargetsStateService.dispatchRetrieveSuggestionTargets( + this.source, + options.pageSize, + options.currentPage + ); + }); + } + + public getTargetUuid(target: SuggestionTarget) { + return this.suggestionService.getTargetUuid(target); + } +} diff --git a/src/app/notifications/suggestion-targets/suggestion-targets.actions.ts b/src/app/notifications/suggestion-targets/suggestion-targets.actions.ts new file mode 100644 index 00000000000..281e07d99e1 --- /dev/null +++ b/src/app/notifications/suggestion-targets/suggestion-targets.actions.ts @@ -0,0 +1,157 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../shared/ngrx/type'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; + + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const SuggestionTargetActionTypes = { + ADD_TARGETS: type('dspace/integration/openaire/suggestions/target/ADD_TARGETS'), + CLEAR_TARGETS: type('dspace/integration/openaire/suggestions/target/CLEAR_TARGETS'), + RETRIEVE_TARGETS_BY_SOURCE: type('dspace/integration/openaire/suggestions/target/RETRIEVE_TARGETS_BY_SOURCE'), + RETRIEVE_TARGETS_BY_SOURCE_ERROR: type('dspace/integration/openaire/suggestions/target/RETRIEVE_TARGETS_BY_SOURCE_ERROR'), + ADD_USER_SUGGESTIONS: type('dspace/integration/openaire/suggestions/target/ADD_USER_SUGGESTIONS'), + REFRESH_USER_SUGGESTIONS: type('dspace/integration/openaire/suggestions/target/REFRESH_USER_SUGGESTIONS'), + MARK_USER_SUGGESTIONS_AS_VISITED: type('dspace/integration/openaire/suggestions/target/MARK_USER_SUGGESTIONS_AS_VISITED') +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to retrieve all the Suggestion Targets. + */ +export class RetrieveTargetsBySourceAction implements Action { + type = SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE; + payload: { + source: string; + elementsPerPage: number; + currentPage: number; + }; + + /** + * Create a new RetrieveTargetsBySourceAction. + * + * @param source + * the source for which to retrieve suggestion targets + * @param elementsPerPage + * the number of targets per page + * @param currentPage + * The page number to retrieve + */ + constructor(source: string, elementsPerPage: number, currentPage: number) { + this.payload = { + source, + elementsPerPage, + currentPage + }; + } +} + +/** + * An ngrx action for retrieving 'all Suggestion Targets' error. + */ +export class RetrieveAllTargetsErrorAction implements Action { + type = SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE_ERROR; +} + +/** + * An ngrx action to load the Suggestion Target objects. + */ +export class AddTargetAction implements Action { + type = SuggestionTargetActionTypes.ADD_TARGETS; + payload: { + targets: SuggestionTarget[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddTargetAction. + * + * @param targets + * the list of targets + * @param totalPages + * the total available pages of targets + * @param currentPage + * the current page + * @param totalElements + * the total available Suggestion Targets + */ + constructor(targets: SuggestionTarget[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + targets, + totalPages, + currentPage, + totalElements + }; + } + +} + +/** + * An ngrx action to load the user Suggestion Target object. + * Called by the ??? effect. + */ +export class AddUserSuggestionsAction implements Action { + type = SuggestionTargetActionTypes.ADD_USER_SUGGESTIONS; + payload: { + suggestionTargets: SuggestionTarget[]; + }; + + /** + * Create a new AddUserSuggestionsAction. + * + * @param suggestionTargets + * the user suggestions target + */ + constructor(suggestionTargets: SuggestionTarget[]) { + this.payload = { suggestionTargets }; + } + +} + +/** + * An ngrx action to reload the user Suggestion Target object. + * Called by the ??? effect. + */ +export class RefreshUserSuggestionsAction implements Action { + type = SuggestionTargetActionTypes.REFRESH_USER_SUGGESTIONS; +} + +/** + * An ngrx action to Mark User Suggestions As Visited. + * Called by the ??? effect. + */ +export class MarkUserSuggestionsAsVisitedAction implements Action { + type = SuggestionTargetActionTypes.MARK_USER_SUGGESTIONS_AS_VISITED; +} + +/** + * An ngrx action to clear targets state. + */ +export class ClearSuggestionTargetsAction implements Action { + type = SuggestionTargetActionTypes.CLEAR_TARGETS; +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type SuggestionTargetsActions + = AddTargetAction + | AddUserSuggestionsAction + | ClearSuggestionTargetsAction + | MarkUserSuggestionsAsVisitedAction + | RetrieveTargetsBySourceAction + | RetrieveAllTargetsErrorAction + | RefreshUserSuggestionsAction; diff --git a/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts b/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts new file mode 100644 index 00000000000..f8be925d8ba --- /dev/null +++ b/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { + AddTargetAction, + AddUserSuggestionsAction, + RetrieveAllTargetsErrorAction, + RetrieveTargetsBySourceAction, + SuggestionTargetActionTypes, +} from './suggestion-targets.actions'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { SuggestionsService } from '../suggestions.service'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + + +/** + * Provides effect methods for the Suggestion Targets actions. + */ +@Injectable() +export class SuggestionTargetsEffects { + + /** + * Retrieve all Suggestion Targets managing pagination and errors. + */ + retrieveTargetsBySource$ = createEffect(() => this.actions$.pipe( + ofType(SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE), + switchMap((action: RetrieveTargetsBySourceAction) => { + return this.suggestionsService.getTargets( + action.payload.source, + action.payload.elementsPerPage, + action.payload.currentPage + ).pipe( + map((targets: PaginatedList) => + new AddTargetAction(targets.page, targets.totalPages, targets.currentPage, targets.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return of(new RetrieveAllTargetsErrorAction()); + }) + ); + }) + )); + + /** + * Show a notification on error. + */ + retrieveAllTargetsErrorAction$ = createEffect(() => this.actions$.pipe( + ofType(SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('suggestion.target.error.service.retrieve')); + }) + ), { dispatch: false }); + + /** + * Fetch the current user suggestion + */ + refreshUserSuggestionsAction$ = createEffect(() => this.actions$.pipe( + ofType(SuggestionTargetActionTypes.REFRESH_USER_SUGGESTIONS), + switchMap(() => { + return this.store$.select((state: any) => state.core.auth.userId) + .pipe( + switchMap((userId: string) => { + return this.suggestionsService.retrieveCurrentUserSuggestions(userId) + .pipe( + map((suggestionTargets: SuggestionTarget[]) => new AddUserSuggestionsAction(suggestionTargets)), + catchError((errors) => of(errors)) + ); + }), + catchError((errors) => of(errors)) + ); + })) + ); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {SuggestionsService} suggestionsService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private suggestionsService: SuggestionsService + ) { + } +} diff --git a/src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts b/src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts new file mode 100644 index 00000000000..6ba29303b34 --- /dev/null +++ b/src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts @@ -0,0 +1,101 @@ +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; +import { SuggestionTargetActionTypes, SuggestionTargetsActions } from './suggestion-targets.actions'; + + +/** + * The interface representing the OpenAIRE suggestion targets state. + */ +export interface SuggestionTargetState { + targets: SuggestionTarget[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; + currentUserTargets: SuggestionTarget[]; + currentUserTargetsVisited: boolean; +} + +/** + * Used for the OpenAIRE Suggestion Target state initialization. + */ +const SuggestionTargetInitialState: SuggestionTargetState = { + targets: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0, + currentUserTargets: null, + currentUserTargetsVisited: false +}; + +/** + * The OpenAIRE Broker Topic Reducer + * + * @param state + * the current state initialized with SuggestionTargetInitialState + * @param action + * the action to perform on the state + * @return SuggestionTargetState + * the new state + */ +export function SuggestionTargetsReducer(state = SuggestionTargetInitialState, action: SuggestionTargetsActions): SuggestionTargetState { + switch (action.type) { + case SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE: { + return Object.assign({}, state, { + targets: [], + processing: true + }); + } + + case SuggestionTargetActionTypes.ADD_TARGETS: { + return Object.assign({}, state, { + targets: state.targets.concat(action.payload.targets), + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE_ERROR: { + return Object.assign({}, state, { + targets: [], + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0, + }); + } + + case SuggestionTargetActionTypes.ADD_USER_SUGGESTIONS: { + return Object.assign({}, state, { + currentUserTargets: action.payload.suggestionTargets + }); + } + + case SuggestionTargetActionTypes.MARK_USER_SUGGESTIONS_AS_VISITED: { + return Object.assign({}, state, { + currentUserTargetsVisited: true + }); + } + + case SuggestionTargetActionTypes.CLEAR_TARGETS: { + return Object.assign({}, state, { + targets: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0, + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts b/src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts new file mode 100644 index 00000000000..1ca01b10f29 --- /dev/null +++ b/src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts @@ -0,0 +1,164 @@ +import { Injectable } from '@angular/core'; + +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + + +import { + ClearSuggestionTargetsAction, + MarkUserSuggestionsAsVisitedAction, + RefreshUserSuggestionsAction, + RetrieveTargetsBySourceAction +} from './suggestion-targets.actions'; +import { SuggestionNotificationsState } from '../../notifications/notifications.reducer'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; +import { + getCurrentUserSuggestionTargetsSelector, getCurrentUserSuggestionTargetsVisitedSelector, + getSuggestionTargetCurrentPageSelector, + getSuggestionTargetTotalsSelector, + isReciterSuggestionTargetProcessingSelector, + isSuggestionTargetLoadedSelector, + suggestionTargetObjectSelector +} from '../../suggestion-notifications/selectors'; + +/** + * The service handling the Suggestion targets State. + */ +@Injectable() +export class SuggestionTargetsStateService { + + /** + * Initialize the service variables. + * @param {Store} store + */ + constructor(private store: Store) { } + + /** + * Returns the list of Suggestion Targets from the state. + * + * @return Observable + * The list of Suggestion Targets. + */ + public getSuggestionTargets(): Observable { + return this.store.pipe(select(suggestionTargetObjectSelector())); + } + + /** + * Returns the information about the loading status of the Suggestion Targets (if it's running or not). + * + * @return Observable + * 'true' if the targets are loading, 'false' otherwise. + */ + public isSuggestionTargetsLoading(): Observable { + return this.store.pipe( + select(isSuggestionTargetLoadedSelector), + map((loaded: boolean) => !loaded) + ); + } + + /** + * Returns the information about the loading status of the Suggestion Targets (whether or not they were loaded). + * + * @return Observable + * 'true' if the targets are loaded, 'false' otherwise. + */ + public isSuggestionTargetsLoaded(): Observable { + return this.store.pipe(select(isSuggestionTargetLoadedSelector)); + } + + /** + * Returns the information about the processing status of the Suggestion Targets (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the targets (ex.: a REST call), 'false' otherwise. + */ + public isSuggestionTargetsProcessing(): Observable { + return this.store.pipe(select(isReciterSuggestionTargetProcessingSelector)); + } + + /** + * Returns, from the state, the total available pages of the Suggestion Targets. + * + * @return Observable + * The number of the Suggestion Targets pages. + */ + public getSuggestionTargetsTotalPages(): Observable { + return this.store.pipe(select(getSuggestionTargetTotalsSelector)); + } + + /** + * Returns the current page of the Suggestion Targets, from the state. + * + * @return Observable + * The number of the current Suggestion Targets page. + */ + public getSuggestionTargetsCurrentPage(): Observable { + return this.store.pipe(select(getSuggestionTargetCurrentPageSelector)); + } + + /** + * Returns the total number of the Suggestion Targets. + * + * @return Observable + * The number of the Suggestion Targets. + */ + public getSuggestionTargetsTotals(): Observable { + return this.store.pipe(select(getSuggestionTargetTotalsSelector)); + } + + /** + * Dispatch a request to change the Suggestion Targets state, retrieving the targets from the server. + * + * @param source + * the source for which to retrieve suggestion targets + * @param elementsPerPage + * The number of the targets per page. + * @param currentPage + * The number of the current page. + */ + public dispatchRetrieveSuggestionTargets(source: string, elementsPerPage: number, currentPage: number): void { + this.store.dispatch(new RetrieveTargetsBySourceAction(source, elementsPerPage, currentPage)); + } + + /** + * Returns, from the state, the suggestion targets for the current user. + * + * @return Observable + * The Suggestion Targets object. + */ + public getCurrentUserSuggestionTargets(): Observable { + return this.store.pipe(select(getCurrentUserSuggestionTargetsSelector)); + } + + /** + * Returns, from the state, whether or not the user has consulted their suggestion targets. + * + * @return Observable + * True if user already visited, false otherwise. + */ + public hasUserVisitedSuggestions(): Observable { + return this.store.pipe(select(getCurrentUserSuggestionTargetsVisitedSelector)); + } + + /** + * Dispatch a new MarkUserSuggestionsAsVisitedAction + */ + public dispatchMarkUserSuggestionsAsVisitedAction(): void { + this.store.dispatch(new MarkUserSuggestionsAsVisitedAction()); + } + + /** + * Dispatch an action to clear the Reciter Suggestion Targets state. + */ + public dispatchClearSuggestionTargetsAction(): void { + this.store.dispatch(new ClearSuggestionTargetsAction()); + } + + /** + * Dispatch an action to refresh the user suggestions. + */ + public dispatchRefreshUserSuggestionsAction(): void { + this.store.dispatch(new RefreshUserSuggestionsAction()); + } +} diff --git a/src/app/notifications/suggestion.service.spec.ts b/src/app/notifications/suggestion.service.spec.ts new file mode 100644 index 00000000000..082c7c3f04e --- /dev/null +++ b/src/app/notifications/suggestion.service.spec.ts @@ -0,0 +1,176 @@ +import { SuggestionsService } from './suggestions.service'; +import { ResearcherProfileDataService } from '../core/profile/researcher-profile-data.service'; + +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { ResearcherProfile } from '../core/profile/model/researcher-profile.model'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { mockSuggestionPublicationOne } from '../shared/mocks/publication-claim.mock'; +import { ResourceType } from '../core/shared/resource-type'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; + + + +describe('SuggestionsService test', () => { + let scheduler: TestScheduler; + let service: SuggestionsService; + let researcherProfileService: ResearcherProfileDataService; + let suggestionsDataService: SuggestionsDataService; + let suggestionTargetDataService: SuggestionTargetDataService; + let translateService: any = { + instant: (str) => str, + }; + const suggestionTarget = { + id: '1234:4321', + display: 'display', + source: 'source', + total: 8, + type: new ResourceType('suggestiontarget') + }; + + const mockResercherProfile = { + id: '1234', + uuid: '1234', + visible: true + }; + + function initTestService() { + return new SuggestionsService( + researcherProfileService, + suggestionsDataService, + suggestionTargetDataService, + translateService + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + findById: createSuccessfulRemoteDataObject$(mockResercherProfile as ResearcherProfile), + findRelatedItemId: observableOf('1234'), + }); + + suggestionTargetDataService = jasmine.createSpyObj('suggestionTargetsDataService', { + getTargets: observableOf(null), + findById: observableOf(null), + }); + + suggestionsDataService = jasmine.createSpyObj('suggestionsDataService', { + searchBy: observableOf(null), + delete: observableOf(null), + deleteSuggestion: createSuccessfulRemoteDataObject$({}), + getSuggestionsByTargetAndSource : observableOf(null), + clearSuggestionRequests : null, + getTargetsByUser: observableOf(null), + }); + + service = initTestService(); + + }); + + describe('Suggestion service', () => { + it('should create', () => { + expect(service).toBeDefined(); + }); + + it('should get targets', () => { + const sortOptions = new SortOptions('display', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: 1, + sort: sortOptions + }; + service.getTargets('source', 10, 1); + expect(suggestionTargetDataService.getTargets).toHaveBeenCalledWith('source', findListOptions); + }); + + it('should get suggestions', () => { + const sortOptions = new SortOptions('display', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: 1, + sort: sortOptions + }; + service.getSuggestions('source:target', 10, 1, sortOptions); + expect(suggestionsDataService.getSuggestionsByTargetAndSource).toHaveBeenCalledWith('target', 'source', findListOptions); + }); + + it('should clear suggestions', () => { + service.clearSuggestionRequests(); + expect(suggestionsDataService.clearSuggestionRequests).toHaveBeenCalled(); + }); + + it('should delete reviewed suggestion', () => { + service.deleteReviewedSuggestion('1234'); + expect(suggestionsDataService.deleteSuggestion).toHaveBeenCalledWith('1234'); + }); + + it('should retrieve current user suggestions', () => { + service.retrieveCurrentUserSuggestions('1234'); + expect(researcherProfileService.findById).toHaveBeenCalledWith('1234', true); + }); + + it('should approve and import suggestion', () => { + spyOn(service, 'resolveCollectionId'); + const workspaceitemService = {importExternalSourceEntry: (x,y) => observableOf(null)}; + service.approveAndImport(workspaceitemService as unknown as WorkspaceitemDataService, mockSuggestionPublicationOne, '1234'); + expect(service.resolveCollectionId).toHaveBeenCalled(); + }); + + it('should approve and import suggestions', () => { + spyOn(service, 'approveAndImport'); + const workspaceitemService = {importExternalSourceEntry: (x,y) => observableOf(null)}; + service.approveAndImportMultiple(workspaceitemService as unknown as WorkspaceitemDataService, [mockSuggestionPublicationOne], '1234'); + expect(service.approveAndImport).toHaveBeenCalledWith(workspaceitemService as unknown as WorkspaceitemDataService, mockSuggestionPublicationOne, '1234'); + }); + + it('should delete suggestion', () => { + spyOn(service, 'deleteReviewedSuggestion').and.returnValue(createSuccessfulRemoteDataObject$({})); + service.ignoreSuggestion('1234'); + expect(service.deleteReviewedSuggestion).toHaveBeenCalledWith('1234'); + }); + + it('should delete suggestions', () => { + spyOn(service, 'ignoreSuggestion'); + service.ignoreSuggestionMultiple([mockSuggestionPublicationOne]); + expect(service.ignoreSuggestion).toHaveBeenCalledWith(mockSuggestionPublicationOne.id); + }); + + it('should get target Uuid', () => { + expect(service.getTargetUuid(suggestionTarget as SuggestionTarget)).toBe('4321'); + expect(service.getTargetUuid({id: ''} as SuggestionTarget)).toBe(null); + }); + + it('should get suggestion interpolation', () => { + const result = service.getNotificationSuggestionInterpolation(suggestionTarget as SuggestionTarget); + expect(result.count).toEqual(suggestionTarget.total); + expect(result.source).toEqual('suggestion.source.' + suggestionTarget.source); + expect(result.type).toEqual('suggestion.type.' + suggestionTarget.source); + expect(result.suggestionId).toEqual(suggestionTarget.id); + expect(result.displayName).toEqual(suggestionTarget.display); + }); + + it('should translate suggestion type', () => { + expect(service.translateSuggestionType('source')).toEqual('suggestion.type.source'); + }); + + it('should translate suggestion source', () => { + expect(service.translateSuggestionSource('source')).toEqual('suggestion.source.source'); + }); + + it('should resolve collection id', () => { + expect(service.resolveCollectionId(mockSuggestionPublicationOne, '1234')).toEqual('1234'); + }); + + it('should check if collection is fixed', () => { + expect(service.isCollectionFixed([mockSuggestionPublicationOne])).toBeFalse(); + }); + }); +}); diff --git a/src/app/notifications/suggestions-notification/suggestions-notification.component.html b/src/app/notifications/suggestions-notification/suggestions-notification.component.html new file mode 100644 index 00000000000..838bdb95ad4 --- /dev/null +++ b/src/app/notifications/suggestions-notification/suggestions-notification.component.html @@ -0,0 +1,9 @@ + + +
+
+ {{ 'notification.suggestion.please' | translate }} + {{ 'notification.suggestion.review' | translate}} +
+
+
diff --git a/src/app/notifications/suggestions-notification/suggestions-notification.component.scss b/src/app/notifications/suggestions-notification/suggestions-notification.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/notifications/suggestions-notification/suggestions-notification.component.ts b/src/app/notifications/suggestions-notification/suggestions-notification.component.ts new file mode 100644 index 00000000000..107da67fce8 --- /dev/null +++ b/src/app/notifications/suggestions-notification/suggestions-notification.component.ts @@ -0,0 +1,41 @@ +import { Component, OnInit } from '@angular/core'; + +import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; +import { SuggestionsService } from '../suggestions.service'; +import { Observable } from 'rxjs'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; + +/** + * Show suggestions notification, used on myDSpace and Profile pages + */ +@Component({ + selector: 'ds-suggestions-notification', + templateUrl: './suggestions-notification.component.html', + styleUrls: ['./suggestions-notification.component.scss'] +}) +export class SuggestionsNotificationComponent implements OnInit { + + /** + * The user suggestion targets. + */ + suggestionsRD$: Observable; + + constructor( + private suggestionTargetsStateService: SuggestionTargetsStateService, + private suggestionsService: SuggestionsService + ) { } + + ngOnInit() { + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + this.suggestionsRD$ = this.suggestionTargetsStateService.getCurrentUserSuggestionTargets(); + } + + /** + * Interpolated params to build the notification suggestions notification. + * @param suggestionTarget + */ + public getNotificationSuggestionInterpolation(suggestionTarget: SuggestionTarget): any { + return this.suggestionsService.getNotificationSuggestionInterpolation(suggestionTarget); + } + +} diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.html b/src/app/notifications/suggestions-popup/suggestions-popup.component.html new file mode 100644 index 00000000000..474dfa329e7 --- /dev/null +++ b/src/app/notifications/suggestions-popup/suggestions-popup.component.html @@ -0,0 +1,27 @@ +
+ +
+ + diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.scss b/src/app/notifications/suggestions-popup/suggestions-popup.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts b/src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts new file mode 100644 index 00000000000..c8d59115d81 --- /dev/null +++ b/src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts @@ -0,0 +1,77 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SuggestionsPopupComponent } from './suggestions-popup.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { mockSuggestionTargetsObjectOne } from '../../shared/mocks/publication-claim-targets.mock'; +import { SuggestionsService } from '../suggestions.service'; + +describe('SuggestionsPopupComponent', () => { + let component: SuggestionsPopupComponent; + let fixture: ComponentFixture; + + const suggestionStateService = jasmine.createSpyObj('SuggestionTargetsStateService', { + hasUserVisitedSuggestions: jasmine.createSpy('hasUserVisitedSuggestions'), + getCurrentUserSuggestionTargets: jasmine.createSpy('getCurrentUserSuggestionTargets'), + dispatchMarkUserSuggestionsAsVisitedAction: jasmine.createSpy('dispatchMarkUserSuggestionsAsVisitedAction'), + dispatchRefreshUserSuggestionsAction: jasmine.createSpy('dispatchRefreshUserSuggestionsAction') + }); + + const mockNotificationInterpolation = { count: 12, source: 'source', suggestionId: 'id', displayName: 'displayName' }; + const suggestionService = jasmine.createSpyObj('SuggestionService', { + getNotificationSuggestionInterpolation: + jasmine.createSpy('getNotificationSuggestionInterpolation').and.returnValue(mockNotificationInterpolation) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ SuggestionsPopupComponent ], + providers: [ + { provide: SuggestionTargetsStateService, useValue: suggestionStateService }, + { provide: SuggestionsService, useValue: suggestionService }, + ], + schemas: [NO_ERRORS_SCHEMA] + + }) + .compileComponents(); + })); + + describe('should create', () => { + + beforeEach(() => { + fixture = TestBed.createComponent(SuggestionsPopupComponent); + component = fixture.componentInstance; + spyOn(component, 'initializePopup').and.returnValue(null); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.initializePopup).toHaveBeenCalled(); + }); + + }); + + describe('when there are publication suggestions', () => { + + beforeEach(() => { + suggestionStateService.hasUserVisitedSuggestions.and.returnValue(observableOf(false)); + suggestionStateService.getCurrentUserSuggestionTargets.and.returnValue(observableOf([mockSuggestionTargetsObjectOne])); + suggestionStateService.dispatchMarkUserSuggestionsAsVisitedAction.and.returnValue(observableOf(null)); + suggestionStateService.dispatchRefreshUserSuggestionsAction.and.returnValue(observableOf(null)); + + fixture = TestBed.createComponent(SuggestionsPopupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should show a notification when new publication suggestions are available', () => { + expect(suggestionStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(suggestionStateService.dispatchMarkUserSuggestionsAsVisitedAction).toHaveBeenCalled(); + }); + + }); +}); diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.ts b/src/app/notifications/suggestions-popup/suggestions-popup.component.ts new file mode 100644 index 00000000000..1921c4fe93d --- /dev/null +++ b/src/app/notifications/suggestions-popup/suggestions-popup.component.ts @@ -0,0 +1,82 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; +import { SuggestionsService } from '../suggestions.service'; +import { take, takeUntil } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; +import { combineLatest, Observable, of, Subject } from 'rxjs'; +import { trigger } from '@angular/animations'; + + +import { fromTopEnter } from '../../shared/animations/fromTop'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; + +/** + * Show suggestions on a popover window, used on the homepage + */ +@Component({ + selector: 'ds-suggestions-popup', + templateUrl: './suggestions-popup.component.html', + styleUrls: ['./suggestions-popup.component.scss'], + animations: [ + trigger('enterLeave', [ + fromTopEnter + ]) + ], +}) +export class SuggestionsPopupComponent implements OnInit, OnDestroy { + + labelPrefix = 'notification.'; + + subscription; + + suggestionsRD$: Observable; + + + constructor( + private suggestionTargetsStateService: SuggestionTargetsStateService, + private suggestionsService: SuggestionsService + ) { } + + ngOnInit() { + this.initializePopup(); + } + + public initializePopup() { + const notifier = new Subject(); + this.subscription = combineLatest([ + this.suggestionTargetsStateService.getCurrentUserSuggestionTargets().pipe(take(2)), + this.suggestionTargetsStateService.hasUserVisitedSuggestions() + ]).pipe(takeUntil(notifier)).subscribe(([suggestions, visited]) => { + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + if (isNotEmpty(suggestions)) { + if (!visited) { + this.suggestionsRD$ = of(suggestions); + this.suggestionTargetsStateService.dispatchMarkUserSuggestionsAsVisitedAction(); + notifier.next(null); + notifier.complete(); + } + } + }); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + /** + * Interpolated params to build the notification suggestions notification. + * @param suggestionTarget + */ + public getNotificationSuggestionInterpolation(suggestionTarget: SuggestionTarget): any { + return this.suggestionsService.getNotificationSuggestionInterpolation(suggestionTarget); + } + + /** + * Hide popup from view + */ + public removePopup() { + this.suggestionsRD$ = null; + } +} diff --git a/src/app/notifications/suggestions.service.ts b/src/app/notifications/suggestions.service.ts new file mode 100644 index 00000000000..2fa85a56718 --- /dev/null +++ b/src/app/notifications/suggestions.service.ts @@ -0,0 +1,303 @@ +import { Injectable } from '@angular/core'; + +import { of, forkJoin, Observable } from 'rxjs'; +import { catchError, map, mergeMap, take } from 'rxjs/operators'; + +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { RemoteData } from '../core/data/remote-data'; +import { PaginatedList } from '../core/data/paginated-list.model'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { ResearcherProfile } from '../core/profile/model/researcher-profile.model'; +import { + getAllSucceededRemoteDataPayload, + getFinishedRemoteData, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteListPayload, +} from '../core/shared/operators'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NoContent } from '../core/shared/NoContent.model'; +import { environment } from '../../environments/environment'; +import { WorkspaceItem } from '../core/submission/models/workspaceitem.model'; +import {FindListOptions} from '../core/data/find-list-options.model'; +import {SuggestionConfig} from '../../config/suggestion-config.interfaces'; +import { ResearcherProfileDataService } from '../core/profile/researcher-profile-data.service'; +import { getSuggestionPageRoute } from '../suggestions-page/suggestions-page-routing-paths'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; +import { Suggestion } from '../core/notifications/models/suggestion.model'; + +/** + * useful for multiple approvals and ignores operation + * */ +export interface SuggestionBulkResult { + success: number; + fails: number; +} + +/** + * The service handling all Suggestion Target requests to the REST service. + */ +@Injectable() +export class SuggestionsService { + + /** + * Initialize the service variables. + * @param {ResearcherProfileDataService} researcherProfileService + * @param {SuggestionTargetDataService} suggestionTargetDataService + * @param {SuggestionsDataService} suggestionsDataService + * @param translateService + */ + constructor( + private researcherProfileService: ResearcherProfileDataService, + private suggestionsDataService: SuggestionsDataService, + private suggestionTargetDataService: SuggestionTargetDataService, + private translateService: TranslateService + ) { + } + + /** + * Return the list of Suggestion Target managing pagination and errors. + * + * @param source + * The source for which to retrieve targets + * @param elementsPerPage + * The number of the target per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Suggestion Targets. + */ + public getTargets(source, elementsPerPage, currentPage): Observable> { + const sortOptions = new SortOptions('display', SortDirection.ASC); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + + return this.suggestionTargetDataService.getTargets(source, findListOptions).pipe( + getFinishedRemoteData(), + take(1), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Suggestion Target from the Search Target REST service'); + } + }) + ); + } + + /** + * Return the list of review suggestions Target managing pagination and errors. + * + * @param targetId + * The target id for which to find suggestions. + * @param elementsPerPage + * The number of the target per page + * @param currentPage + * The page number to retrieve + * @param sortOptions + * The sort options + * @return Observable>> + * The list of Suggestion. + */ + public getSuggestions(targetId: string, elementsPerPage, currentPage, sortOptions: SortOptions): Observable> { + const [source, target] = targetId.split(':'); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + + return this.suggestionsDataService.getSuggestionsByTargetAndSource(target, source, findListOptions).pipe( + getAllSucceededRemoteDataPayload() + ); + } + + /** + * Clear suggestions requests from cache + */ + public clearSuggestionRequests() { + this.suggestionsDataService.clearSuggestionRequests(); + } + + /** + * Used to delete Suggestion + * @suggestionId + */ + public deleteReviewedSuggestion(suggestionId: string): Observable> { + return this.suggestionsDataService.deleteSuggestion(suggestionId).pipe( + map((response: RemoteData) => { + if (response.isSuccess) { + return response; + } else { + throw new Error('Can\'t delete Suggestion from the Search Target REST service'); + } + }), + take(1) + ); + } + + /** + * Retrieve suggestion targets for the given user + * + * @param userUuid + * The EPerson id for which to retrieve suggestion targets + */ + public retrieveCurrentUserSuggestions(userUuid: string): Observable { + return this.researcherProfileService.findById(userUuid, true).pipe( + getFirstCompletedRemoteData(), + mergeMap((profile: RemoteData ) => { + if (isNotEmpty(profile) && profile.hasSucceeded && isNotEmpty(profile.payload)) { + return this.researcherProfileService.findRelatedItemId(profile.payload).pipe( + mergeMap((itemId: string) => { + return this.suggestionsDataService.getTargetsByUser(itemId).pipe( + getFirstSucceededRemoteListPayload() + ); + }) + ); + } else { + return of([]); + } + }), + catchError(() => of([])) + ); + } + + /** + * Perform the approve and import operation over a single suggestion + * @param suggestion target suggestion + * @param collectionId the collectionId + * @param workspaceitemService injected dependency + * @private + */ + public approveAndImport(workspaceitemService: WorkspaceitemDataService, + suggestion: Suggestion, + collectionId: string): Observable { + + const resolvedCollectionId = this.resolveCollectionId(suggestion, collectionId); + return workspaceitemService.importExternalSourceEntry(suggestion.externalSourceUri, resolvedCollectionId) + .pipe( + getFirstSucceededRemoteDataPayload(), + catchError(() => of(null)) + ); + } + + /** + * Perform the delete operation over a single suggestion. + * @param suggestionId + */ + public ignoreSuggestion(suggestionId): Observable> { + return this.deleteReviewedSuggestion(suggestionId).pipe( + catchError(() => of(null)) + ); + } + + /** + * Perform a bulk approve and import operation. + * @param workspaceitemService injected dependency + * @param suggestions the array containing the suggestions + * @param collectionId the collectionId + */ + public approveAndImportMultiple(workspaceitemService: WorkspaceitemDataService, + suggestions: Suggestion[], + collectionId: string): Observable { + + return forkJoin(suggestions.map((suggestion: Suggestion) => + this.approveAndImport(workspaceitemService, suggestion, collectionId))) + .pipe(map((results: WorkspaceItem[]) => { + return { + success: results.filter((result) => result != null).length, + fails: results.filter((result) => result == null).length + }; + }), take(1)); + } + + /** + * Perform a bulk ignoreSuggestion operation. + * @param suggestions the array containing the suggestions + */ + public ignoreSuggestionMultiple(suggestions: Suggestion[]): Observable { + return forkJoin(suggestions.map((suggestion: Suggestion) => this.ignoreSuggestion(suggestion.id))) + .pipe(map((results: RemoteData[]) => { + return { + success: results.filter((result) => result != null).length, + fails: results.filter((result) => result == null).length + }; + }), take(1)); + } + + /** + * Get the researcher uuid (for navigation purpose) from a target instance. + * TODO Find a better way + * @param target + * @return the researchUuid + */ + public getTargetUuid(target: SuggestionTarget): string { + const tokens = target.id.split(':'); + return tokens.length === 2 ? tokens[1] : null; + } + + /** + * Interpolated params to build the notification suggestions notification. + * @param suggestionTarget + */ + public getNotificationSuggestionInterpolation(suggestionTarget: SuggestionTarget): any { + return { + count: suggestionTarget.total, + source: this.translateService.instant(this.translateSuggestionSource(suggestionTarget.source)), + type: this.translateService.instant(this.translateSuggestionType(suggestionTarget.source)), + suggestionId: suggestionTarget.id, + displayName: suggestionTarget.display, + url: getSuggestionPageRoute(suggestionTarget.id) + }; + } + + public translateSuggestionType(source: string): string { + return 'suggestion.type.' + source; + } + + public translateSuggestionSource(source: string): string { + return 'suggestion.source.' + source; + } + + /** + * If the provided collectionId ha no value, tries to resolve it by suggestion source. + * @param suggestion + * @param collectionId + */ + public resolveCollectionId(suggestion: Suggestion, collectionId): string { + if (hasValue(collectionId)) { + return collectionId; + } + return environment.suggestion + .find((suggestionConf: SuggestionConfig) => suggestionConf.source === suggestion.source) + .collectionId; + } + + /** + * Return true if all the suggestion are configured with the same fixed collection + * in the configuration. + * @param suggestions + */ + public isCollectionFixed(suggestions: Suggestion[]): boolean { + return this.getFixedCollectionIds(suggestions).length === 1; + } + + private getFixedCollectionIds(suggestions: Suggestion[]): string[] { + const collectionIds = {}; + suggestions.forEach((suggestion: Suggestion) => { + const conf = environment.suggestion.find((suggestionConf: SuggestionConfig) => suggestionConf.source === suggestion.source); + if (hasValue(conf)) { + collectionIds[conf.collectionId] = true; + } + }); + return Object.keys(collectionIds); + } +} diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 44783da84e8..193e80e3a24 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -8,6 +8,7 @@

{{'profile.head' | translate}}

+
diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index 0e2902de33c..339f611d24b 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -12,7 +12,10 @@ import { ThemedProfilePageComponent } from './themed-profile-page.component'; import { FormModule } from '../shared/form/form.module'; import { UiSwitchModule } from 'ngx-ui-switch'; import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profile-claim-item-modal.component'; - +import { NotificationsModule } from '../notifications/notifications.module'; +import { + SuggestionsNotificationComponent +} from '../notifications/suggestions-notification/suggestions-notification.component'; @NgModule({ imports: [ @@ -20,14 +23,16 @@ import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profi CommonModule, SharedModule, FormModule, - UiSwitchModule + UiSwitchModule, + NotificationsModule ], exports: [ ProfilePageComponent, ThemedProfilePageComponent, ProfilePageMetadataFormComponent, ProfilePageSecurityFormComponent, - ProfilePageResearcherFormComponent + ProfilePageResearcherFormComponent, + SuggestionsNotificationComponent ], declarations: [ ProfilePageComponent, diff --git a/src/app/quality-assurance-notifications-pages/notifications-pages-routing-paths.ts b/src/app/quality-assurance-notifications-pages/notifications-pages-routing-paths.ts new file mode 100644 index 00000000000..c517ddafd49 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-pages-routing-paths.ts @@ -0,0 +1,7 @@ + +export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const NOTIFICATIONS_RECITER_SUGGESTION_PATH = 'suggestion-targets'; + +export function getQualityAssuranceEditRoute() { + return `/${QUALITY_ASSURANCE_EDIT_PATH}`; +} diff --git a/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts b/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts new file mode 100644 index 00000000000..c57b22a028b --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts @@ -0,0 +1,122 @@ +import { NgModule } from '@angular/core'; +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 { NOTIFICATIONS_RECITER_SUGGESTION_PATH } from './notifications-pages-routing-paths'; +import { NotificationsSuggestionTargetsPageComponent } from './notifications-suggestion-targets-page/notifications-suggestion-targets-page.component'; +import { AdminNotificationsPublicationClaimPageResolver } from './notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; +import { QUALITY_ASSURANCE_EDIT_PATH } from './notifications-pages-routing-paths'; +import { QualityAssuranceTopicsPageComponent } from './quality-assurance-topics-page/quality-assurance-topics-page.component'; +import { QualityAssuranceEventsPageComponent } from './quality-assurance-events-page/quality-assurance-events-page.component'; +import { QualityAssuranceTopicsPageResolver } from './quality-assurance-topics-page/quality-assurance-topics-page-resolver.service'; +import { QualityAssuranceEventsPageResolver } from './quality-assurance-events-page/quality-assurance-events-page.resolver'; +import { QualityAssuranceSourcePageComponent } from './quality-assurance-source-page-component/quality-assurance-source-page.component'; +import { QualityAssuranceSourcePageResolver } from './quality-assurance-source-page-component/quality-assurance-source-page-resolver.service'; +import { QualityAssuranceBreadcrumbResolver } from '../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; +import { QualityAssuranceBreadcrumbService } from '../core/breadcrumbs/quality-assurance-breadcrumb.service'; +import { + SourceDataResolver +} from './quality-assurance-source-page-component/quality-assurance-source-data.resolver'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: `${NOTIFICATIONS_RECITER_SUGGESTION_PATH}`, + component: NotificationsSuggestionTargetsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + reciterSuggestionTargetParams: AdminNotificationsPublicationClaimPageResolver + }, + data: { + title: 'admin.notifications.recitersuggestion.page.title', + breadcrumbKey: 'admin.notifications.recitersuggestion', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, + component: QualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + 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: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}`, + component: QualityAssuranceSourcePageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceSourceParams: QualityAssuranceSourcePageResolver, + sourceData: SourceDataResolver + }, + data: { + title: 'admin.notifications.source.breadcrumbs', + breadcrumbKey: 'admin.notifications.source', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, + component: QualityAssuranceEventsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceEventsParams: QualityAssuranceEventsPageResolver + }, + data: { + title: 'admin.notifications.event.page.title', + breadcrumbKey: 'admin.notifications.event', + showBreadcrumbsFluid: false + } + } + ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + AdminNotificationsPublicationClaimPageResolver, + SourceDataResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceTopicsPageResolver, + QualityAssuranceEventsPageResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceBreadcrumbResolver, + QualityAssuranceBreadcrumbService + ] +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class NotificationsPageRoutingModule { + +} diff --git a/src/app/quality-assurance-notifications-pages/notifications-pages.module.ts b/src/app/quality-assurance-notifications-pages/notifications-pages.module.ts new file mode 100644 index 00000000000..809b111337c --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-pages.module.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { NotificationsPageRoutingModule } from './notifications-pages-routing.module'; +import { NotificationsSuggestionTargetsPageComponent } from './notifications-suggestion-targets-page/notifications-suggestion-targets-page.component'; +import { QualityAssuranceTopicsPageComponent } from './quality-assurance-topics-page/quality-assurance-topics-page.component'; +import { QualityAssuranceEventsPageComponent } from './quality-assurance-events-page/quality-assurance-events-page.component'; +import { QualityAssuranceSourcePageComponent } from './quality-assurance-source-page-component/quality-assurance-source-page.component'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { SharedModule } from '../shared/shared.module'; +import { CoreModule } from '../core/core.module'; + + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + NotificationsPageRoutingModule, + NotificationsModule + ], + declarations: [ + NotificationsSuggestionTargetsPageComponent, + QualityAssuranceTopicsPageComponent, + QualityAssuranceEventsPageComponent, + QualityAssuranceSourcePageComponent + ], + exports: [ + QualityAssuranceEventsPageComponent + ], + entryComponents: [] +}) +/** + * This module handles all components related to the notifications pages + */ +export class NotificationsPageModule { + +} diff --git a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts new file mode 100644 index 00000000000..783387281e1 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ + +export interface AdminNotificationsPublicationClaimPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() + +export class AdminNotificationsPublicationClaimPageResolver 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): AdminNotificationsPublicationClaimPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.html b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.html new file mode 100644 index 00000000000..11db25fa7bf --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.scss b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts new file mode 100644 index 00000000000..bbca7f8d393 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts @@ -0,0 +1,41 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotificationsSuggestionTargetsPageComponent } from './notifications-suggestion-targets-page.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { + AdminNotificationsPublicationClaimPageComponent +} from '../../admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; + +describe('NotificationsSuggestionTargetsPageComponent', () => { + let component: NotificationsSuggestionTargetsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot() + ], + declarations: [ + NotificationsSuggestionTargetsPageComponent + ], + providers: [ + AdminNotificationsPublicationClaimPageComponent + ], + 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/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.ts b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.ts new file mode 100644 index 00000000000..65b7f7667e6 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-notifications-reciter-page', + templateUrl: './notifications-suggestion-targets-page.component.html', + styleUrls: ['./notifications-suggestion-targets-page.component.scss'] +}) +export class NotificationsSuggestionTargetsPageComponent { + +} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.html b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.html new file mode 100644 index 00000000000..315209d3429 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.spec.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.spec.ts new file mode 100644 index 00000000000..bc1fb214535 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { QualityAssuranceEventsPageComponent } from './quality-assurance-events-page.component'; + +describe('QualityAssuranceEventsPageComponent', () => { + let component: QualityAssuranceEventsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ QualityAssuranceEventsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceEventsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceEventsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.ts new file mode 100644 index 00000000000..0f9ddd509f2 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA events related to a specific topic. + */ +@Component({ + selector: 'ds-quality-assurance-events-page', + templateUrl: './quality-assurance-events-page.component.html' +}) +export class QualityAssuranceEventsPageComponent { + +} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver.ts new file mode 100644 index 00000000000..30b838dd138 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AssuranceEventsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class QualityAssuranceEventsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceEventsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AssuranceEventsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver.ts new file mode 100644 index 00000000000..ee16b55dd6c --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { QualityAssuranceSourceObject } from '../../core/notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceService } from '../../notifications/qa/source/quality-assurance-source.service'; +import {environment} from '../../../environments/environment'; +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class SourceDataResolver implements Resolve> { + private pageSize = environment.qualityAssuranceConfig.pageSize; + /** + * Initialize the effect class variables. + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + */ + constructor( + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private router: Router + ) { } + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.qualityAssuranceSourceService.getSources(this.pageSize, 0).pipe( + map((sources: PaginatedList) => { + if (sources.page.length === 1) { + this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]); + } + return sources.page; + })); + } + + /** + * + * @param route url path + * @returns url path + */ + getResolvedUrl(route: ActivatedRouteSnapshot): string { + return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/'); + } +} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service.ts new file mode 100644 index 00000000000..3adf319f363 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-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 QualityAssuranceSourcePageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class QualityAssuranceSourcePageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceSourcePageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): QualityAssuranceSourcePageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.html b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.html new file mode 100644 index 00000000000..709103cf3d2 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.spec.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.spec.ts new file mode 100644 index 00000000000..8fa34d84d95 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.spec.ts @@ -0,0 +1,27 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QualityAssuranceSourcePageComponent } from './quality-assurance-source-page.component'; + +describe('QualityAssuranceSourcePageComponent', () => { + let component: QualityAssuranceSourcePageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ QualityAssuranceSourcePageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceSourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create QualityAssuranceSourcePageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.ts new file mode 100644 index 00000000000..f066364708c --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA sources. + */ +@Component({ + selector: 'ds-quality-assurance-source-page-component', + templateUrl: './quality-assurance-source-page.component.html', +}) +export class QualityAssuranceSourcePageComponent {} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service.ts new file mode 100644 index 00000000000..b0e43e549fe --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-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 QualityAssuranceTopicsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class QualityAssuranceTopicsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceTopicsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): QualityAssuranceTopicsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.html b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.html new file mode 100644 index 00000000000..fc905ad7240 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.spec.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.spec.ts new file mode 100644 index 00000000000..fe3614a7701 --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { QualityAssuranceTopicsPageComponent } from './quality-assurance-topics-page.component'; + +describe('QualityAssuranceTopicsPageComponent', () => { + let component: QualityAssuranceTopicsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ QualityAssuranceTopicsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceTopicsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create QualityAssuranceTopicsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.ts b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.ts new file mode 100644 index 00000000000..af0c8085afb --- /dev/null +++ b/src/app/quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA topics related to a specific source. + */ +@Component({ + selector: 'ds-notification-qa-page', + templateUrl: './quality-assurance-topics-page.component.html' +}) +export class QualityAssuranceTopicsPageComponent { + +} diff --git a/src/app/shared/abstract-component-loader/abstract-component-loader.component.html b/src/app/shared/abstract-component-loader/abstract-component-loader.component.html new file mode 100644 index 00000000000..2035dbadd08 --- /dev/null +++ b/src/app/shared/abstract-component-loader/abstract-component-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/abstract-component-loader/abstract-component-loader.component.ts b/src/app/shared/abstract-component-loader/abstract-component-loader.component.ts new file mode 100644 index 00000000000..76b30fa10b9 --- /dev/null +++ b/src/app/shared/abstract-component-loader/abstract-component-loader.component.ts @@ -0,0 +1,143 @@ +import { Component, ComponentRef, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core'; +import { ThemeService } from '../theme-support/theme.service'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { Subscription } from 'rxjs'; +import { DynamicComponentLoaderDirective } from './dynamic-component-loader.directive'; + +/** + * To create a new loader component you will need to: + *
    + *
  • Create a new LoaderComponent component extending this component
  • + *
  • Point the templateUrl to this component's templateUrl
  • + *
  • Add all the @Input()/@Output() names that the dynamically generated components should inherit from the loader to the inputNames/outputNames lists
  • + *
  • Create a decorator file containing the new decorator function, a map containing all the collected {@link Component}s and a function to retrieve the {@link Component}
  • + *
  • Call the function to retrieve the correct {@link Component} in getComponent()
  • + *
  • Add all the @Input()s you had to used in getComponent() in the inputNamesDependentForComponent array
  • + *
+ */ +@Component({ + selector: 'ds-abstract-component-loader', + templateUrl: './abstract-component-loader.component.html', +}) +export abstract class AbstractComponentLoaderComponent implements OnInit, OnChanges, OnDestroy { + + /** + * Directive to determine where the dynamic child component is located + */ + @ViewChild(DynamicComponentLoaderDirective, { static: true }) componentDirective: DynamicComponentLoaderDirective; + + /** + * The reference to the dynamic component + */ + protected compRef: ComponentRef; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + protected subs: Subscription[] = []; + + /** + * The @Input() that are used to find the matching component using {@link getComponent}. When the value of + * one of these @Input() change this loader needs to retrieve the best matching component again using the + * {@link getComponent} method. + */ + protected inputNamesDependentForComponent: (keyof this & string)[] = []; + + /** + * The list of the @Input() names that should be passed down to the dynamically created components. + */ + protected inputNames: (keyof this & string)[] = []; + + /** + * The list of the @Output() names that should be passed down to the dynamically created components. + */ + protected outputNames: (keyof this & string)[] = []; + + constructor( + protected themeService: ThemeService, + ) { + } + + /** + * Set up the dynamic child component + */ + ngOnInit(): void { + this.instantiateComponent(); + } + + /** + * Whenever the inputs change, update the inputs of the dynamic component + */ + ngOnChanges(changes: SimpleChanges): void { + if (hasValue(this.compRef)) { + if (this.inputNamesDependentForComponent.some((name: keyof this & string) => hasValue(changes[name]) && changes[name].previousValue !== changes[name].currentValue)) { + // Recreate the component when the @Input()s used by getComponent() aren't up-to-date anymore + this.destroyComponentInstance(); + this.instantiateComponent(); + } else { + this.connectInputsAndOutputs(); + } + } + } + + ngOnDestroy(): void { + this.subs + .filter((subscription: Subscription) => hasValue(subscription)) + .forEach((subscription: Subscription) => subscription.unsubscribe()); + this.destroyComponentInstance(); + } + + /** + * Creates the component and connects the @Input() & @Output() from the ThemedComponent to its child Component. + */ + public instantiateComponent(): void { + const component: GenericConstructor = this.getComponent(); + + const viewContainerRef: ViewContainerRef = this.componentDirective.viewContainerRef; + viewContainerRef.clear(); + + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined, + }, + ); + + this.connectInputsAndOutputs(); + } + + /** + * Destroys the themed component and calls it's `ngOnDestroy` + */ + public destroyComponentInstance(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = null; + } + } + + /** + * Fetch the component depending on the item's entity type, metadata representation type and context + */ + public abstract getComponent(): GenericConstructor; + + /** + * Connect the inputs and outputs of this component to the dynamic component, + * to ensure they're in sync, the ngOnChanges method will automatically be called by setInput + */ + public connectInputsAndOutputs(): void { + if (isNotEmpty(this.inputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { + this.inputNames.filter((name: string) => this[name] !== undefined).filter((name: string) => this[name] !== this.compRef.instance[name]).forEach((name: string) => { + // Using setInput will automatically trigger the ngOnChanges + this.compRef.setInput(name, this[name]); + }); + } + if (isNotEmpty(this.outputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { + this.outputNames.filter((name: string) => this[name] !== undefined).filter((name: string) => this[name] !== this.compRef.instance[name]).forEach((name: string) => { + this.compRef.instance[name] = this[name]; + }); + } + } + +} diff --git a/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts b/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts new file mode 100644 index 00000000000..8c77df1cdb8 --- /dev/null +++ b/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts @@ -0,0 +1,16 @@ +import { Directive, ViewContainerRef } from '@angular/core'; + +/** + * Directive used as a hook to know where to inject the dynamic loaded component + */ +@Directive({ + selector: '[dsDynamicComponentLoader]' +}) +export class DynamicComponentLoaderDirective { + + constructor( + public viewContainerRef: ViewContainerRef, + ) { + } + +} diff --git a/src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.html b/src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.html new file mode 100644 index 00000000000..1463235bee9 --- /dev/null +++ b/src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.html @@ -0,0 +1,55 @@ +
+ + + +
+ + + + + + + + + diff --git a/src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.scss b/src/app/shared/correction-suggestion/item-withdrawn-reinstate-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/correction-suggestion/withdrawn-reinstate-modal.component.ts b/src/app/shared/correction-suggestion/withdrawn-reinstate-modal.component.ts new file mode 100644 index 00000000000..de842eb1e5b --- /dev/null +++ b/src/app/shared/correction-suggestion/withdrawn-reinstate-modal.component.ts @@ -0,0 +1,74 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { ModalBeforeDismiss } from '../interfaces/modal-before-dismiss.interface'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; + +@Component({ + selector: 'ds-item-withdrawn-reinstate-modal', + templateUrl: './item-withdrawn-reinstate-modal.component.html', + styleUrls: ['./item-withdrawn-reinstate-modal.component.scss'] +}) +/** + * Represents a modal component for withdrawing or reinstating an item. + * Implements the ModalBeforeDismiss interface. + */ +export class ItemWithdrawnReinstateModalComponent implements ModalBeforeDismiss { + + /** + * The reason for withdrawing or reinstating a suggestion. + */ + reason: string; + /** + * Indicates whether the item can be withdrawn. + */ + canWithdraw: boolean; + /** + * BehaviorSubject that represents the submitted state. + * Emits a boolean value indicating whether the form has been submitted or not. + */ + submitted$: BehaviorSubject = new BehaviorSubject(false); + /** + * Event emitter for creating a QA event. + * @event createQAEvent + */ + @Output() createQAEvent: EventEmitter = new EventEmitter(); + + constructor( + protected activeModal: NgbActiveModal, + protected authorizationService: AuthorizationDataService, + ) {} + + /** + * Closes the modal. + */ + onModalClose() { + this.activeModal.close(); + } + + /** + * Determines whether the modal can be dismissed. + * @returns {boolean} True if the modal can be dismissed, false otherwise. + */ + beforeDismiss(): boolean { + // prevent the modal from being dismissed after version creation is initiated + return !this.submitted$.getValue(); + } + + /** + * Handles the submission of the modal form. + * Emits the reason for withdrawal or reinstatement through the createQAEvent output. + */ + onModalSubmit() { + this.submitted$.next(true); + this.createQAEvent.emit(this.reason); + } + + /** + * Sets the withdrawal state of the component. + * @param state The new withdrawal state. + */ + public setWithdraw(state: boolean) { + this.canWithdraw = state; + } +} diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts index e28a416f230..14b4313ac22 100644 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts @@ -23,6 +23,10 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Community } from '../../core/shared/community.model'; import { Collection } from '../../core/shared/collection.model'; import flatten from 'lodash/flatten'; +import { DsoWithdrawnReinstateModalService } from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service'; +import { AuthService } from 'src/app/core/auth/auth.service'; +import { AuthServiceMock } from '../mocks/auth.service.mock'; +import { CorrectionTypeDataService } from 'src/app/core/submission/correctiontype-data.service'; describe('DSOEditMenuResolver', () => { @@ -39,6 +43,8 @@ describe('DSOEditMenuResolver', () => { let researcherProfileService; let notificationsService; let translate; + let dsoWithdrawnReinstateModalService; + let correctionsDataService; const dsoRoute = (dso: DSpaceObject) => { return { @@ -141,6 +147,14 @@ describe('DSOEditMenuResolver', () => { error: {}, }); + dsoWithdrawnReinstateModalService = jasmine.createSpyObj('dsoWithdrawnReinstateModalService', { + openCreateWithdrawnReinstateModal: {}, + }); + + correctionsDataService = jasmine.createSpyObj('correctionsDataService', { + findByItem: observableOf([]) + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule], declarations: [AdminSidebarComponent], @@ -152,6 +166,9 @@ describe('DSOEditMenuResolver', () => { {provide: ResearcherProfileDataService, useValue: researcherProfileService}, {provide: TranslateService, useValue: translate}, {provide: NotificationsService, useValue: notificationsService}, + {provide: DsoWithdrawnReinstateModalService, useValue: dsoWithdrawnReinstateModalService}, + {provide: AuthService, useValue: new AuthServiceMock()}, + {provide: CorrectionTypeDataService, useValue: correctionsDataService}, { provide: NgbModal, useValue: { open: () => {/*comment*/ @@ -350,7 +367,7 @@ describe('DSOEditMenuResolver', () => { route = dsoRoute(testItem); }); - it('should return Item-specific entries', (done) => { + it('should return Item-specific entries', () => { const result = resolver.getDsoMenus(testObject, route, state); combineLatest(result).pipe(map(flatten)).subscribe((menu) => { const orcidEntry = menu.find(entry => entry.id === 'orcid-dso'); @@ -371,20 +388,18 @@ describe('DSOEditMenuResolver', () => { expect(claimEntry.active).toBeFalse(); expect(claimEntry.visible).toBeFalse(); expect(claimEntry.model.type).toEqual(MenuItemType.ONCLICK); - done(); }); }); - it('should not return Community/Collection-specific entries', (done) => { + it('should not return Community/Collection-specific entries', () => { const result = resolver.getDsoMenus(testObject, route, state); combineLatest(result).pipe(map(flatten)).subscribe((menu) => { const subscribeEntry = menu.find(entry => entry.id === 'subscribe'); expect(subscribeEntry).toBeFalsy(); - done(); }); }); - it('should return as third part the common list ', (done) => { + it('should return as third part the common list ', () => { const result = resolver.getDsoMenus(testObject, route, state); combineLatest(result).pipe(map(flatten)).subscribe((menu) => { const editEntry = menu.find(entry => entry.id === 'edit-dso'); @@ -395,7 +410,6 @@ describe('DSOEditMenuResolver', () => { expect((editEntry.model as LinkMenuItemModel).link).toEqual( '/items/test-item-uuid/edit/metadata' ); - done(); }); }); }); diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.ts index 1ade4578405..bcded3acd5a 100644 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.ts +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.ts @@ -8,7 +8,9 @@ import { LinkMenuItemModel } from '../menu/menu-item/models/link.model'; import { Item } from '../../core/shared/item.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { OnClickMenuItemModel } from '../menu/menu-item/models/onclick.model'; -import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + getFirstCompletedRemoteData, getRemoteDataPayload, +} from '../../core/shared/operators'; import { map, switchMap } from 'rxjs/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; @@ -21,6 +23,11 @@ import { getDSORoute } from '../../app-routing-paths'; import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; import { NotificationsService } from '../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { DsoWithdrawnReinstateModalService, REQUEST_REINSTATE, REQUEST_WITHDRAWN } from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { RequestParam } from '../../core/cache/models/request-param.model'; +import { CorrectionTypeDataService } from '../../core/submission/correctiontype-data.service'; import { SubscriptionModalComponent } from '../subscriptions/subscription-modal/subscription-modal.component'; import { Community } from '../../core/shared/community.model'; import { Collection } from '../../core/shared/collection.model'; @@ -42,6 +49,9 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection protected researcherProfileService: ResearcherProfileDataService, protected notificationsService: NotificationsService, protected translate: TranslateService, + protected dsoWithdrawnReinstateModalService: DsoWithdrawnReinstateModalService, + private auth: AuthService, + private correctionTypeDataService: CorrectionTypeDataService ) { } @@ -123,14 +133,20 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection */ protected getItemMenu(dso): Observable { if (dso instanceof Item) { + const findListTopicOptions: FindListOptions = { + searchParams: [new RequestParam('target', dso.uuid)] + }; return combineLatest([ this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, dso.self), this.dsoVersioningModalService.isNewVersionButtonDisabled(dso), this.dsoVersioningModalService.getVersioningTooltipMessage(dso, 'item.page.version.hasDraft', 'item.page.version.create'), this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, dso.self), this.authorizationService.isAuthorized(FeatureID.CanClaimItem, dso.self), + this.correctionTypeDataService.findByItem(dso.uuid, false).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload()) ]).pipe( - map(([canCreateVersion, disableVersioning, versionTooltip, canSynchronizeWithOrcid, canClaimItem]) => { + map(([canCreateVersion, disableVersioning, versionTooltip, canSynchronizeWithOrcid, canClaimItem, correction]) => { const isPerson = this.getDsoType(dso) === 'person'; return [ { @@ -174,6 +190,34 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection icon: 'hand-paper', index: 3 }, + { + id: 'withdrawn-item', + active: false, + visible: dso.isArchived && correction?.page.some((c) => c.topic === REQUEST_WITHDRAWN), + model: { + type: MenuItemType.ONCLICK, + text:'item.page.withdrawn', + function: () => { + this.dsoWithdrawnReinstateModalService.openCreateWithdrawnReinstateModal(dso, 'request-withdrawn', dso.isArchived); + } + } as OnClickMenuItemModel, + icon: 'eye-slash', + index: 4 + }, + { + id: 'reinstate-item', + active: false, + visible: dso.isWithdrawn && correction?.page.some((c) => c.topic === REQUEST_REINSTATE), + model: { + type: MenuItemType.ONCLICK, + text:'item.page.reinstate', + function: () => { + this.dsoWithdrawnReinstateModalService.openCreateWithdrawnReinstateModal(dso, 'request-reinstate', dso.isArchived); + } + } as OnClickMenuItemModel, + icon: 'eye', + index: 5 + } ]; }), ); @@ -263,4 +307,5 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection return menu; }); } + } diff --git a/src/app/shared/dso-page/dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service.ts b/src/app/shared/dso-page/dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service.ts new file mode 100644 index 00000000000..d5f47ed57bf --- /dev/null +++ b/src/app/shared/dso-page/dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ItemWithdrawnReinstateModalComponent } from '../../correction-suggestion/withdrawn-reinstate-modal.component'; +import { + QualityAssuranceEventDataService +} from '../../../core/notifications/qa/events/quality-assurance-event-data.service'; +import { + QualityAssuranceEventObject +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { take } from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Item } from 'src/app/core/shared/item.model'; + +export const REQUEST_WITHDRAWN = 'REQUEST/WITHDRAWN'; +export const REQUEST_REINSTATE = 'REQUEST/REINSTATE'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Service for managing the withdrawn/reinstate modal for a DSO. + */ +export class DsoWithdrawnReinstateModalService { + + constructor( + protected router: Router, + protected modalService: NgbModal, + protected itemService: ItemDataService, + private notificationsService: NotificationsService, + protected authorizationService: AuthorizationDataService, + private translateService: TranslateService, + protected qaEventDataService: QualityAssuranceEventDataService, + ) {} + + /** + * Open the create withdrawn modal for the provided dso + */ + openCreateWithdrawnReinstateModal(dso: Item, correctionType: 'request-reinstate' | 'request-withdrawn', state: boolean): void { + const target = dso.id; + // Open modal + const activeModal = this.modalService.open(ItemWithdrawnReinstateModalComponent); + (activeModal.componentInstance as ItemWithdrawnReinstateModalComponent).setWithdraw(state); + (activeModal.componentInstance as ItemWithdrawnReinstateModalComponent).createQAEvent + .pipe( + take(1) + ).subscribe( + (reasone) => { + this.sendQARequest(target, correctionType, reasone); + activeModal.close(); + } + ); + } + + /** + * Sends a quality assurance request. + * + * @param target - The target - the item's UUID. + * @param correctionType - The type of correction. + * @param reason - The reason for the request. + * Reloads the current page in order to update the withdrawn/reinstate button. + * and desplay a notification box. + */ + sendQARequest(target: string, correctionType: 'request-reinstate' | 'request-withdrawn', reason: string): void { + this.qaEventDataService.postData(target, correctionType, '', reason) + .pipe ( + getFirstCompletedRemoteData() + ) + .subscribe((res: RemoteData) => { + if (res.hasSucceeded) { + const message = (correctionType === 'request-withdrawn') + ? 'correction-type.manage-relation.action.notification.withdrawn' + : 'correction-type.manage-relation.action.notification.reinstate'; + + this.notificationsService.success(this.translateService.get(message)); + this.authorizationService.invalidateAuthorizationsRequestCache(); + this.reloadPage(true); + } else { + this.notificationsService.error(this.translateService.get('correction-type.manage-relation.action.notification.error')); + } + }); + } + + /** + * Reloads the current page or navigates to a specified URL. + * @param self - A boolean indicating whether to reload the current page (true) or navigate to a specified URL (false). + * @param urlToNavigateTo - The URL to navigate to if `self` is false. + * skipLocationChange:true means dont update the url to / when navigating + */ + reloadPage(self: boolean, urlToNavigateTo?: string) { + const url = self ? this.router.url : urlToNavigateTo; + this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { + this.router.navigate([`/${url}`]); + }); + } +} + diff --git a/src/app/shared/mocks/active-router.mock.ts b/src/app/shared/mocks/active-router.mock.ts index 672cdcd8f6c..00bfced12c8 100644 --- a/src/app/shared/mocks/active-router.mock.ts +++ b/src/app/shared/mocks/active-router.mock.ts @@ -5,19 +5,29 @@ import { BehaviorSubject } from 'rxjs'; export class MockActivatedRoute { private _testParams?: any; + private _testUrl?: any; // ActivatedRoute.params is Observable private subject?: BehaviorSubject = new BehaviorSubject(this.testParams); + // ActivatedRoute.url is Observable + private urlSubject?: BehaviorSubject = new BehaviorSubject(this.testUrl); params = this.subject.asObservable(); queryParams = this.subject.asObservable(); + url = this.urlSubject.asObservable(); - constructor(params?: Params) { + constructor(params?: Params, url?: any) { if (params) { this.testParams = params; } else { this.testParams = {}; } + + if (url) { + this.testUrl = url; + } else { + this.testUrl = {}; + } } // Test parameters @@ -31,4 +41,11 @@ export class MockActivatedRoute { get snapshot() { return { params: this.testParams, queryParams: this.testParams }; } + + //ActivatedRoute.url + get testUrl() { return this._testUrl; } + set testUrl(url: any) { + this._testUrl = url; + this.urlSubject.next(url); + } } diff --git a/src/app/shared/mocks/notifications.mock.ts b/src/app/shared/mocks/notifications.mock.ts new file mode 100644 index 00000000000..86f7b74a809 --- /dev/null +++ b/src/app/shared/mocks/notifications.mock.ts @@ -0,0 +1,1883 @@ +import { of as observableOf } from 'rxjs'; +import { ResourceType } from '../../core/shared/resource-type'; +import { + QualityAssuranceTopicObject +} from '../../core/notifications/qa/models/quality-assurance-topic.model'; +import { + QualityAssuranceEventObject +} from '../../core/notifications/qa/models/quality-assurance-event.model'; +import { + QualityAssuranceTopicDataService +} from '../../core/notifications/qa/topics/quality-assurance-topic-data.service'; +import { + QualityAssuranceEventDataService +} from '../../core/notifications/qa/events/quality-assurance-event-data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { SearchResult } from '../search/models/search-result.model'; +import { + QualityAssuranceSourceObject +} from '../../core/notifications/qa/models/quality-assurance-source.model'; + +// REST Mock --------------------------------------------------------------------- +// ------------------------------------------------------------------------------- + +// Items +// ------------------------------------------------------------------------------- + +const ItemMockPid1: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174001', + uuid: 'ITEM4567-e89b-12d3-a456-426614174001', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Index nominum et rerum' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid2: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174004', + uuid: 'ITEM4567-e89b-12d3-a456-426614174004', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'UNA NUOVA RILETTURA DELL\u0027 ARISTOTELE DI FRANZ BRENTANO ALLA LUCE DI ALCUNI INEDITI' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid3: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174005', + uuid: 'ITEM4567-e89b-12d3-a456-426614174005', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Sustainable development' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid4: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174006', + uuid: 'ITEM4567-e89b-12d3-a456-426614174006', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Reply to Critics' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid5: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174007', + uuid: 'ITEM4567-e89b-12d3-a456-426614174007', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'PROGETTAZIONE, SINTESI E VALUTAZIONE DELL\u0027ATTIVITA\u0027 ANTIMICOBATTERICA ED ANTIFUNGINA DI NUOVI DERIVATI ETEROCICLICI' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid6: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174008', + uuid: 'ITEM4567-e89b-12d3-a456-426614174008', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Donald Davidson' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid7: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174009', + uuid: 'ITEM4567-e89b-12d3-a456-426614174009', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Missing abstract article' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid8: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174002', + uuid: 'ITEM4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Egypt, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid9: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174003', + uuid: 'ITEM4567-e89b-12d3-a456-426614174003', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Morocco, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid10: Item = Object.assign( + new Item(), + { + handle: '10713/29832', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'P23e4567-e89b-12d3-a456-426614174002', + uuid: 'P23e4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const NotificationsMockDspaceObject: SearchResult = Object.assign( + new SearchResult(), + { + handle: '10713/29832', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'P23e4567-e89b-12d3-a456-426614174002', + uuid: 'P23e4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://demo.dspace.org/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +// Sources +// ------------------------------------------------------------------------------- + +export const qualityAssuranceSourceObjectMorePid: QualityAssuranceSourceObject = { + type: new ResourceType('qasource'), + id: 'ENRICH!MORE!PID', + lastEvent: '2020/10/09 10:11 UTC', + totalEvents: 33, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qasources/ENRICH!MORE!PID' + } + } +}; + +export const qualityAssuranceSourceObjectMoreAbstract: QualityAssuranceSourceObject = { + type: new ResourceType('qasource'), + id: 'ENRICH!MORE!ABSTRACT', + lastEvent: '2020/09/08 21:14 UTC', + totalEvents: 5, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qasources/ENRICH!MORE!ABSTRACT' + } + } +}; + +export const qualityAssuranceSourceObjectMissingPid: QualityAssuranceSourceObject = { + type: new ResourceType('qasource'), + id: 'ENRICH!MISSING!PID', + lastEvent: '2020/10/01 07:36 UTC', + totalEvents: 4, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qasources/ENRICH!MISSING!PID' + } + } +}; + +// Topics +// ------------------------------------------------------------------------------- + +export const qualityAssuranceTopicObjectMorePid: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MORE!PID', + name: 'ENRICH/MORE/PID', + lastEvent: '2020/10/09 10:11 UTC', + totalEvents: 33, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MORE!PID' + } + } +}; + +export const qualityAssuranceTopicObjectMoreAbstract: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MORE!ABSTRACT', + name: 'ENRICH/MORE/ABSTRACT', + lastEvent: '2020/09/08 21:14 UTC', + totalEvents: 5, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MORE!ABSTRACT' + } + } +}; + +export const qualityAssuranceTopicObjectMissingPid: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!PID', + name: 'ENRICH/MISSING/PID', + lastEvent: '2020/10/01 07:36 UTC', + totalEvents: 4, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!PID' + } + } +}; + +export const qualityAssuranceTopicObjectMissingAbstract: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!ABSTRACT', + name: 'ENRICH/MISSING/ABSTRACT', + lastEvent: '2020/10/08 16:14 UTC', + totalEvents: 71, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!ABSTRACT' + } + } +}; + +export const qualityAssuranceTopicObjectMissingAcm: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!SUBJECT!ACM', + name: 'ENRICH/MISSING/SUBJECT/ACM', + lastEvent: '2020/09/21 17:51 UTC', + totalEvents: 18, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!SUBJECT!ACM' + } + } +}; + +export const qualityAssuranceTopicObjectMissingProject: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!PROJECT', + name: 'ENRICH/MISSING/PROJECT', + lastEvent: '2020/09/17 10:28 UTC', + totalEvents: 6, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!PROJECT' + } + } +}; + +// Events +// ------------------------------------------------------------------------------- + +export const qualityAssuranceEventObjectMissingPid: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174001', + uuid: '123e4567-e89b-12d3-a456-426614174001', + type: new ResourceType('qaevent'), + originalId: 'oai:www.openstarts.units.it:10077/21486', + title: 'Index nominum et rerum', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.18848/1447-9494/cgp/v15i09/45934', + pidHref: 'https://doi.org/10.18848/1447-9494/cgp/v15i09/45934', + abstract: null, + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing PID' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174001', + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174001/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174001/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid1)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid2: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174004', + uuid: '123e4567-e89b-12d3-a456-426614174004', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21486', + title: 'UNA NUOVA RILETTURA DELL\u0027 ARISTOTELE DI FRANZ BRENTANO ALLA LUCE DI ALCUNI INEDITI', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'urn', + value: 'http://thesis2.sba.units.it/store/handle/item/12238', + pidHref:'http://thesis2.sba.units.it/store/handle/item/12238', + abstract: null, + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing PID' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174004' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174004/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174004/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid2)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid3: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174005', + uuid: '123e4567-e89b-12d3-a456-426614174005', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/554', + title: 'Sustainable development', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.4324/9780203408889', + pidHref: 'https://doi.org/10.4324/9780203408889', + abstract: null, + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing PID' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174005' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174005/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174005/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid3)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid4: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174006', + uuid: '123e4567-e89b-12d3-a456-426614174006', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/10787', + title: 'Reply to Critics', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.1080/13698230.2018.1430104', + pidHref: 'https://doi.org/10.1080/13698230.2018.1430104', + abstract: null, + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing DOI' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174006' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174006/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174006/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid4)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid5: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174007', + uuid: '123e4567-e89b-12d3-a456-426614174007', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/11339', + title: 'PROGETTAZIONE, SINTESI E VALUTAZIONE DELL\u0027ATTIVITA\u0027 ANTIMICOBATTERICA ED ANTIFUNGINA DI NUOVI DERIVATI ETEROCICLICI', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'urn', + value: 'http://thesis2.sba.units.it/store/handle/item/12477', + pidHref:'http://thesis2.sba.units.it/store/handle/item/12477', + abstract: null, + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing PID' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174007' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174007/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174007/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid5)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid6: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174008', + uuid: '123e4567-e89b-12d3-a456-426614174008', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/29860', + title: 'Donald Davidson', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.1111/j.1475-4975.2004.00098.x', + pidHref: 'https://doi.org/10.1111/j.1475-4975.2004.00098.x', + abstract: null, + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing PID' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174008' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174008/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174008/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid6)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingAbstract: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174009', + uuid: '123e4567-e89b-12d3-a456-426614174009', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21110', + title: 'Missing abstract article', + trust: 0.751, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: null, + value: null, + pidHref: null, + abstract: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque vestibulum tellus sed lacinia. Aenean vitae sapien a quam congue ultrices. Sed vehicula sollicitudin ligula, vitae lacinia velit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque vestibulum tellus sed lacinia. Aenean vitae sapien a quam congue ultrices. Sed vehicula sollicitudin ligula, vitae lacinia velit.', + sourceId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null, + reason: 'Missing abstract' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174009' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174009/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174009/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid7)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingProjectFound: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174002', + uuid: '123e4567-e89b-12d3-a456-426614174002', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21838', + title: 'Egypt, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: null, + value: null, + pidHref: null, + abstract: null, + sourceId: null, + acronym: 'PAThs', + code: '687567', + funder: 'EC', + fundingProgram: 'H2020', + jurisdiction: 'EU', + reason: 'Project found', + title: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174002' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174002/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174002/related' + } + }, + target: createSuccessfulRemoteDataObject$(ItemMockPid8), + related: createSuccessfulRemoteDataObject$(ItemMockPid10) +}; + +export const qualityAssuranceEventObjectMissingProjectNotFound: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174003', + uuid: '123e4567-e89b-12d3-a456-426614174003', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21838', + title: 'Morocco, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: null, + value: null, + pidHref: null, + abstract: null, + sourceId: null, + acronym: 'PAThs', + code: '687567B', + funder: 'EC', + fundingProgram: 'H2021', + jurisdiction: 'EU', + title: 'Tracking Unknown Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage', + reason: 'Project not found' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174003' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174003/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174003/related' + } + }, + target: createSuccessfulRemoteDataObject$(ItemMockPid9), + related: createNoContentRemoteDataObject$() +}; + +// Classes +// ------------------------------------------------------------------------------- + +/** + * Mock for [[NotificationsStateService]] + */ +export function getMockNotificationsStateService(): any { + return jasmine.createSpyObj('NotificationsStateService', { + getQualityAssuranceTopics: jasmine.createSpy('getQualityAssuranceTopics'), + isQualityAssuranceTopicsLoading: jasmine.createSpy('isQualityAssuranceTopicsLoading'), + isQualityAssuranceTopicsLoaded: jasmine.createSpy('isQualityAssuranceTopicsLoaded'), + isQualityAssuranceTopicsProcessing: jasmine.createSpy('isQualityAssuranceTopicsProcessing'), + getQualityAssuranceTopicsTotalPages: jasmine.createSpy('getQualityAssuranceTopicsTotalPages'), + getQualityAssuranceTopicsCurrentPage: jasmine.createSpy('getQualityAssuranceTopicsCurrentPage'), + getQualityAssuranceTopicsTotals: jasmine.createSpy('getQualityAssuranceTopicsTotals'), + dispatchRetrieveQualityAssuranceTopics: jasmine.createSpy('dispatchRetrieveQualityAssuranceTopics'), + getQualityAssuranceSource: jasmine.createSpy('getQualityAssuranceSource'), + isQualityAssuranceSourceLoading: jasmine.createSpy('isQualityAssuranceSourceLoading'), + isQualityAssuranceSourceLoaded: jasmine.createSpy('isQualityAssuranceSourceLoaded'), + isQualityAssuranceSourceProcessing: jasmine.createSpy('isQualityAssuranceSourceProcessing'), + getQualityAssuranceSourceTotalPages: jasmine.createSpy('getQualityAssuranceSourceTotalPages'), + getQualityAssuranceSourceCurrentPage: jasmine.createSpy('getQualityAssuranceSourceCurrentPage'), + getQualityAssuranceSourceTotals: jasmine.createSpy('getQualityAssuranceSourceTotals'), + dispatchRetrieveQualityAssuranceSource: jasmine.createSpy('dispatchRetrieveQualityAssuranceSource'), + dispatchMarkUserSuggestionsAsVisitedAction: jasmine.createSpy('dispatchMarkUserSuggestionsAsVisitedAction') + }); +} + +/** + * Mock for [[QualityAssuranceSourceDataService]] + */ + export function getMockQualityAssuranceSourceRestService(): QualityAssuranceTopicDataService { + return jasmine.createSpyObj('QualityAssuranceSourceDataService', { + getSources: jasmine.createSpy('getSources'), + getSource: jasmine.createSpy('getSource'), + }); +} + +/** + * Mock for [[QualityAssuranceTopicDataService]] + */ +export function getMockQualityAssuranceTopicRestService(): QualityAssuranceTopicDataService { + return jasmine.createSpyObj('QualityAssuranceTopicDataService', { + getTopic: jasmine.createSpy('getTopic'), + searchTopicsByTarget: jasmine.createSpy('searchTopicsByTarget'), + searchTopicsBySource: jasmine.createSpy('searchTopicsBySource'), + clearFindAllTopicsRequests: jasmine.createSpy('clearFindAllTopicsRequests'), + }); +} + +/** + * Mock for [[QualityAssuranceEventDataService]] + */ +export function getMockQualityAssuranceEventRestService(): QualityAssuranceEventDataService { + return jasmine.createSpyObj('QualityAssuranceEventDataService', { + getEventsByTopic: jasmine.createSpy('getEventsByTopic'), + getEvent: jasmine.createSpy('getEvent'), + patchEvent: jasmine.createSpy('patchEvent'), + boundProject: jasmine.createSpy('boundProject'), + removeProject: jasmine.createSpy('removeProject'), + clearFindByTopicRequests: jasmine.createSpy('.clearFindByTopicRequests') + }); +} + +/** + * Mock for [[QualityAssuranceEventDataService]] + */ +export function getMockSuggestionsService(): any { + return jasmine.createSpyObj('SuggestionsService', { + getTargets: jasmine.createSpy('getTargets'), + getSuggestions: jasmine.createSpy('getSuggestions'), + clearSuggestionRequests: jasmine.createSpy('clearSuggestionRequests'), + deleteReviewedSuggestion: jasmine.createSpy('deleteReviewedSuggestion'), + retrieveCurrentUserSuggestions: jasmine.createSpy('retrieveCurrentUserSuggestions'), + getTargetUuid: jasmine.createSpy('getTargetUuid'), + }); +} diff --git a/src/app/shared/mocks/publication-claim-targets.mock.ts b/src/app/shared/mocks/publication-claim-targets.mock.ts new file mode 100644 index 00000000000..932a98bee55 --- /dev/null +++ b/src/app/shared/mocks/publication-claim-targets.mock.ts @@ -0,0 +1,42 @@ +import { ResourceType } from '../../core/shared/resource-type'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; + +// REST Mock --------------------------------------------------------------------- +// ------------------------------------------------------------------------------- +export const mockSuggestionTargetsObjectOne: SuggestionTarget = { + type: new ResourceType('suggestiontarget'), + id: 'reciter:gf3d657-9d6d-4a87-b905-fef0f8cae26', + display: 'Bollini, Andrea', + source: 'reciter', + total: 31, + _links: { + target: { + href: 'https://rest.api/rest/api/core/items/gf3d657-9d6d-4a87-b905-fef0f8cae26' + }, + suggestions: { + href: 'https://rest.api/rest/api/integration/suggestions/search/findByTargetAndSource?target=gf3d657-9d6d-4a87-b905-fef0f8cae26c&source=reciter' + }, + self: { + href: 'https://rest.api/rest/api/integration/suggestiontargets/reciter:gf3d657-9d6d-4a87-b905-fef0f8cae26' + } + } +}; + +export const mockSuggestionTargetsObjectTwo: SuggestionTarget = { + type: new ResourceType('suggestiontarget'), + id: 'reciter:nhy567-9d6d-ty67-b905-fef0f8cae26', + display: 'Digilio, Andrea', + source: 'reciter', + total: 12, + _links: { + target: { + href: 'https://rest.api/rest/api/core/items/nhy567-9d6d-ty67-b905-fef0f8cae26' + }, + suggestions: { + href: 'https://rest.api/rest/api/integration/suggestions/search/findByTargetAndSource?target=nhy567-9d6d-ty67-b905-fef0f8cae26&source=reciter' + }, + self: { + href: 'https://rest.api/rest/api/integration/suggestiontargets/reciter:nhy567-9d6d-ty67-b905-fef0f8cae26' + } + } +}; diff --git a/src/app/shared/mocks/publication-claim.mock.ts b/src/app/shared/mocks/publication-claim.mock.ts new file mode 100644 index 00000000000..02364347f4c --- /dev/null +++ b/src/app/shared/mocks/publication-claim.mock.ts @@ -0,0 +1,211 @@ + +// REST Mock --------------------------------------------------------------------- +// ------------------------------------------------------------------------------- + + +import { Suggestion } from '../../core/notifications/models/suggestion.model'; +import { SUGGESTION } from '../../core/notifications/models/suggestion-objects.resource-type'; + +export const mockSuggestionPublicationOne: Suggestion = { + id: '24694773', + display: 'publication one', + source: 'reciter', + externalSourceUri: 'https://dspace7.4science.cloud/server/api/integration/reciterSourcesEntry/pubmed/entryValues/24694772', + score: '48', + evidences: { + acceptedRejectedEvidence: { + score: '2.7', + notes: 'some notes, eventually empty or null' + }, + authorNameEvidence: { + score: '0', + notes: 'some notes, eventually empty or null' + }, + journalCategoryEvidence: { + score: '6', + notes: 'some notes, eventually empty or null' + }, + affiliationEvidence: { + score: 'xxx', + notes: 'some notes, eventually empty or null' + }, + relationshipEvidence: { + score: '9', + notes: 'some notes, eventually empty or null' + }, + educationYearEvidence: { + score: '3.6', + notes: 'some notes, eventually empty or null' + }, + personTypeEvidence: { + score: '4', + notes: 'some notes, eventually empty or null' + }, + articleCountEvidence: { + score: '6.7', + notes: 'some notes, eventually empty or null' + }, + averageClusteringEvidence: { + score: '7', + notes: 'some notes, eventually empty or null' + } + }, + metadata: { + 'dc.identifier.uri': [ + { + value: 'https://publication/0000-0003-3681-2038', + language: null, + authority: null, + confidence: -1, + place: -1 + } as any + ], + 'dc.title': [ + { + value: 'publication one', + language: null, + authority: null, + confidence: -1 + } as any + ], + 'dc.date.issued': [ + { + value: '2010-11-03', + language: null, + authority: null, + confidence: -1 + } as any + ], + 'dspace.entity.type': [ + { + uuid: '95f21fe6-ce38-43d6-96d4-60ae66385a06', + language: null, + value: 'OrgUnit', + place: 0, + authority: null, + confidence: -1 + } as any + ], + 'dc.description': [ + { + uuid: '95f21fe6-ce38-43d6-96d4-60ae66385a06', + language: null, + value: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).", + place: 0, + authority: null, + confidence: -1 + } as any + ] + }, + type: SUGGESTION, + _links: { + target: { + href: 'https://dspace7.4science.cloud/server/api/core/items/gf3d657-9d6d-4a87-b905-fef0f8cae26' + }, + self: { + href: 'https://dspace7.4science.cloud/server/api/integration/suggestions/reciter:gf3d657-9d6d-4a87-b905-fef0f8cae26c:24694772' + } + } +}; + +export const mockSuggestionPublicationTwo: Suggestion = { + id: '24694772', + display: 'publication two', + source: 'reciter', + externalSourceUri: 'https://dspace7.4science.cloud/server/api/integration/reciterSourcesEntry/pubmed/entryValues/24694772', + score: '48', + evidences: { + acceptedRejectedEvidence: { + score: '2.7', + notes: 'some notes, eventually empty or null' + }, + authorNameEvidence: { + score: '0', + notes: 'some notes, eventually empty or null' + }, + journalCategoryEvidence: { + score: '6', + notes: 'some notes, eventually empty or null' + }, + affiliationEvidence: { + score: 'xxx', + notes: 'some notes, eventually empty or null' + }, + relationshipEvidence: { + score: '9', + notes: 'some notes, eventually empty or null' + }, + educationYearEvidence: { + score: '3.6', + notes: 'some notes, eventually empty or null' + }, + personTypeEvidence: { + score: '4', + notes: 'some notes, eventually empty or null' + }, + articleCountEvidence: { + score: '6.7', + notes: 'some notes, eventually empty or null' + }, + averageClusteringEvidence: { + score: '7', + notes: 'some notes, eventually empty or null' + } + }, + metadata: { + 'dc.identifier.uri': [ + { + value: 'https://publication/0000-0003-3681-2038', + language: null, + authority: null, + confidence: -1, + place: -1 + } as any + ], + 'dc.title': [ + { + value: 'publication one', + language: null, + authority: null, + confidence: -1 + } as any + ], + 'dc.date.issued': [ + { + value: '2010-11-03', + language: null, + authority: null, + confidence: -1 + } as any + ], + 'dspace.entity.type': [ + { + uuid: '95f21fe6-ce38-43d6-96d4-60ae66385a06', + language: null, + value: 'OrgUnit', + place: 0, + authority: null, + confidence: -1 + } as any + ], + 'dc.description': [ + { + uuid: '95f21fe6-ce38-43d6-96d4-60ae66385a06', + language: null, + value: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).", + place: 0, + authority: null, + confidence: -1 + } as any + ] + }, + type: SUGGESTION, + _links: { + target: { + href: 'https://dspace7.4science.cloud/server/api/core/items/gf3d657-9d6d-4a87-b905-fef0f8cae26' + }, + self: { + href: 'https://dspace7.4science.cloud/server/api/integration/suggestions/reciter:gf3d657-9d6d-4a87-b905-fef0f8cae26c:24694772' + } + } +}; diff --git a/src/app/shared/mocks/section-upload.service.mock.ts b/src/app/shared/mocks/section-upload.service.mock.ts index ae3515105d9..4e872522c33 100644 --- a/src/app/shared/mocks/section-upload.service.mock.ts +++ b/src/app/shared/mocks/section-upload.service.mock.ts @@ -5,6 +5,9 @@ import { SubmissionFormsConfigDataService } from '../../core/config/submission-f */ export function getMockSectionUploadService(): SubmissionFormsConfigDataService { return jasmine.createSpyObj('SectionUploadService', { + updatePrimaryBitstreamOperation: jasmine.createSpy('updatePrimaryBitstreamOperation'), + updateFilePrimaryBitstream: jasmine.createSpy('updateFilePrimaryBitstream'), + getUploadedFilesData: jasmine.createSpy('getUploadedFilesData'), getUploadedFileList: jasmine.createSpy('getUploadedFileList'), getFileData: jasmine.createSpy('getFileData'), getDefaultPolicies: jasmine.createSpy('getDefaultPolicies'), diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts index 268ae33ab39..385df9dff61 100644 --- a/src/app/shared/mocks/submission.mock.ts +++ b/src/app/shared/mocks/submission.mock.ts @@ -1114,7 +1114,10 @@ export const mockSubmissionState: SubmissionObjectState = Object.assign({}, { isLoading: false, isValid: false, removePending: false - } as any + } as any, + 'duplicates': { + potentialDuplicates: [] + } as any, }, isLoading: false, savePending: false, @@ -1612,7 +1615,13 @@ export const mockUploadFiles = [ } ]; +export const mockUploadFilesData = { + primary: null, + files: JSON.parse(JSON.stringify(mockUploadFiles)) +}; + export const mockFileFormData = { + primary: [true], metadata: { 'dc.title': [ { diff --git a/src/app/shared/mocks/suggestion.mock.ts b/src/app/shared/mocks/suggestion.mock.ts new file mode 100644 index 00000000000..ed7f9045d56 --- /dev/null +++ b/src/app/shared/mocks/suggestion.mock.ts @@ -0,0 +1,1360 @@ +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { SearchResult } from '../search/models/search-result.model'; +import { of as observableOf } from 'rxjs'; + +// REST Mock --------------------------------------------------------------------- +// ------------------------------------------------------------------------------- + +// Items +// ------------------------------------------------------------------------------- + +const ItemMockPid1: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174001', + uuid: 'ITEM4567-e89b-12d3-a456-426614174001', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Index nominum et rerum' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid2: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174004', + uuid: 'ITEM4567-e89b-12d3-a456-426614174004', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'UNA NUOVA RILETTURA DELL\u0027 ARISTOTELE DI FRANZ BRENTANO ALLA LUCE DI ALCUNI INEDITI' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid3: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174005', + uuid: 'ITEM4567-e89b-12d3-a456-426614174005', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Sustainable development' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid4: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174006', + uuid: 'ITEM4567-e89b-12d3-a456-426614174006', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Reply to Critics' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid5: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174007', + uuid: 'ITEM4567-e89b-12d3-a456-426614174007', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'PROGETTAZIONE, SINTESI E VALUTAZIONE DELL\u0027ATTIVITA\u0027 ANTIMICOBATTERICA ED ANTIFUNGINA DI NUOVI DERIVATI ETEROCICLICI' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid6: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174008', + uuid: 'ITEM4567-e89b-12d3-a456-426614174008', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Donald Davidson' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid7: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174009', + uuid: 'ITEM4567-e89b-12d3-a456-426614174009', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Missing abstract article' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid8: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174002', + uuid: 'ITEM4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Egypt, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid9: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174003', + uuid: 'ITEM4567-e89b-12d3-a456-426614174003', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Morocco, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid10: Item = Object.assign( + new Item(), + { + handle: '10713/29832', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'P23e4567-e89b-12d3-a456-426614174002', + uuid: 'P23e4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const OpenaireMockDspaceObject: SearchResult = Object.assign( + new SearchResult(), + { + handle: '10713/29832', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'P23e4567-e89b-12d3-a456-426614174002', + uuid: 'P23e4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +// Classes +// ------------------------------------------------------------------------------- + +/** + * Mock for [[SuggestionNotificationsStateService]] + */ +export function getMockSuggestionNotificationsStateService(): any { + return jasmine.createSpyObj('SuggestionNotificationsStateService', { + getOpenaireBrokerTopics: jasmine.createSpy('getOpenaireBrokerTopics'), + isOpenaireBrokerTopicsLoading: jasmine.createSpy('isOpenaireBrokerTopicsLoading'), + isOpenaireBrokerTopicsLoaded: jasmine.createSpy('isOpenaireBrokerTopicsLoaded'), + isOpenaireBrokerTopicsProcessing: jasmine.createSpy('isOpenaireBrokerTopicsProcessing'), + getOpenaireBrokerTopicsTotalPages: jasmine.createSpy('getOpenaireBrokerTopicsTotalPages'), + getOpenaireBrokerTopicsCurrentPage: jasmine.createSpy('getOpenaireBrokerTopicsCurrentPage'), + getOpenaireBrokerTopicsTotals: jasmine.createSpy('getOpenaireBrokerTopicsTotals'), + dispatchRetrieveOpenaireBrokerTopics: jasmine.createSpy('dispatchRetrieveOpenaireBrokerTopics'), + dispatchMarkUserSuggestionsAsVisitedAction: jasmine.createSpy('dispatchMarkUserSuggestionsAsVisitedAction'), + dispatchRefreshUserSuggestionsAction: undefined + }); +} +/** + * Mock for [[OpenaireBrokerEventRestService]] + */ +export function getMockSuggestionsService(): any { + return jasmine.createSpyObj('SuggestionsService', { + getTargets: jasmine.createSpy('getTargets'), + getSuggestions: observableOf([]), + clearSuggestionRequests: jasmine.createSpy('clearSuggestionRequests'), + deleteReviewedSuggestion: jasmine.createSpy('deleteReviewedSuggestion'), + retrieveCurrentUserSuggestions: jasmine.createSpy('retrieveCurrentUserSuggestions'), + getTargetUuid: jasmine.createSpy('getTargetUuid'), + ignoreSuggestion: observableOf(null), + ignoreSuggestionMultiple: observableOf({success: 1, fails: 0}), + approveAndImportMultiple: observableOf({success: 1, fails: 0}), + approveAndImport: observableOf({id: '1234'}), + isCollectionFixed: false, + translateSuggestionSource: 'testSource', + translateSuggestionType: 'testType', + }); +} diff --git a/src/app/shared/notification-box/notification-box.component.html b/src/app/shared/notification-box/notification-box.component.html new file mode 100644 index 00000000000..8e4b5bc2f6f --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.html @@ -0,0 +1,12 @@ +
+
+
{{ boxConfig.count ?? 0 }}
+
{{ boxConfig.title | translate }}
+
+
diff --git a/src/app/shared/notification-box/notification-box.component.scss b/src/app/shared/notification-box/notification-box.component.scss new file mode 100644 index 00000000000..3bc1d232489 --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.scss @@ -0,0 +1,8 @@ +.box-container { + min-width: max-content; +} + +.box-counter { + font-size: calc(var(--bs-font-size-lg) * 1.5); +} + diff --git a/src/app/shared/notification-box/notification-box.component.spec.ts b/src/app/shared/notification-box/notification-box.component.spec.ts new file mode 100644 index 00000000000..1583e3e7f33 --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.spec.ts @@ -0,0 +1,38 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotificationBoxComponent } from './notification-box.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { + AdminNotifyMetricsBox +} from '../../admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model'; + +describe('NotificationBoxComponent', () => { + let component: NotificationBoxComponent; + let fixture: ComponentFixture; + let mockBoxConfig: AdminNotifyMetricsBox; + + beforeEach(async () => { + mockBoxConfig = { + 'color': '#D4EDDA', + 'title': 'admin-notify-dashboard.delivered', + 'config': 'NOTIFY.outgoing.delivered', + 'count': 79, + 'description': 'box description' + }; + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ NotificationBoxComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(NotificationBoxComponent); + component = fixture.componentInstance; + component.boxConfig = mockBoxConfig; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/notification-box/notification-box.component.ts b/src/app/shared/notification-box/notification-box.component.ts new file mode 100644 index 00000000000..b2a3221d3ec --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.ts @@ -0,0 +1,28 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { + AdminNotifyMetricsBox +} from '../../admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model'; +import { listableObjectComponent } from '../object-collection/shared/listable-object/listable-object.decorator'; +import { + AdminNotifySearchResult +} from '../../admin/admin-notify-dashboard/models/admin-notify-message-search-result.model'; +import { ViewMode } from '../../core/shared/view-mode.model'; + +@listableObjectComponent(AdminNotifySearchResult, ViewMode.ListElement) +@Component({ + selector: 'ds-notification-box', + templateUrl: './notification-box.component.html', + styleUrls: ['./notification-box.component.scss'] +}) +/** + * Component to display the count of notifications for each type of LDN message and to access the related filtered search + * (each box works as a filter button setting a specific search configuration) + */ +export class NotificationBoxComponent { + @Input() boxConfig: AdminNotifyMetricsBox; + @Output() selectedBoxConfig: EventEmitter = new EventEmitter(); + + public onClick(boxConfig: AdminNotifyMetricsBox) { + this.selectedBoxConfig.emit(boxConfig.config); + } +} diff --git a/src/app/shared/notifications/notification/notification.component.scss b/src/app/shared/notifications/notification/notification.component.scss index 06c46b0f5d0..ecfc25fee06 100644 --- a/src/app/shared/notifications/notification/notification.component.scss +++ b/src/app/shared/notifications/notification/notification.component.scss @@ -5,7 +5,8 @@ } .close { - outline: none !important + outline: none !important; + opacity: 0.8; } .notification-icon { diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index 3b0dca80ac7..179a70bfd77 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -58,3 +58,19 @@ + + diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.html b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.html deleted file mode 100644 index 58561f0277d..00000000000 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts index e893fe807b7..9d2fb0ac79d 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts @@ -8,7 +8,7 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ItemListElementComponent } from '../../../object-list/item-list-element/item-types/item/item-list-element.component'; -import { ListableObjectDirective } from './listable-object.directive'; +import { DynamicComponentLoaderDirective } from '../../../abstract-component-loader/dynamic-component-loader.directive'; import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { provideMockStore } from '@ngrx/store/testing'; @@ -18,7 +18,7 @@ const testType = 'TestType'; const testContext = Context.Search; const testViewMode = ViewMode.StandalonePage; -class TestType extends ListableObject { +export class TestType extends ListableObject { getRenderTypes(): (string | GenericConstructor)[] { return [testType]; } @@ -36,7 +36,7 @@ describe('ListableObjectComponentLoaderComponent', () => { }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective], + declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, DynamicComponentLoaderDirective], schemas: [NO_ERRORS_SCHEMA], providers: [ provideMockStore({}), @@ -65,7 +65,7 @@ describe('ListableObjectComponentLoaderComponent', () => { describe('When the component is rendered', () => { it('should call the getListableObjectComponent function with the right types, view mode and context', () => { - expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); + expect(comp.getComponent).toHaveBeenCalled(); }); it('should connectInputsAndOutputs of loaded component', () => { @@ -78,29 +78,29 @@ describe('ListableObjectComponentLoaderComponent', () => { let reloadedObject: any; beforeEach(() => { - spyOn((comp as any), 'instantiateComponent').and.returnValue(null); - spyOn((comp as any).contentChange, 'emit').and.returnValue(null); + spyOn(comp, 'instantiateComponent').and.returnValue(null); + spyOn(comp.contentChange, 'emit').and.returnValue(null); listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance; reloadedObject = 'object'; }); it('should re-instantiate the listable component', fakeAsync(() => { - expect((comp as any).instantiateComponent).not.toHaveBeenCalled(); + expect(comp.instantiateComponent).not.toHaveBeenCalled(); - (listableComponent as any).reloadedObject.emit(reloadedObject); + listableComponent.reloadedObject.emit(reloadedObject); tick(200); - expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject, undefined); + expect(comp.instantiateComponent).toHaveBeenCalledWith(); })); it('should re-emit it as a contentChange', fakeAsync(() => { - expect((comp as any).contentChange.emit).not.toHaveBeenCalled(); + expect(comp.contentChange.emit).not.toHaveBeenCalled(); - (listableComponent as any).reloadedObject.emit(reloadedObject); + listableComponent.reloadedObject.emit(reloadedObject); tick(200); - expect((comp as any).contentChange.emit).toHaveBeenCalledWith(reloadedObject); + expect(comp.contentChange.emit).toHaveBeenCalledWith(reloadedObject); })); }); diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts index 7a3cc42bf5a..d2807686310 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts @@ -1,40 +1,25 @@ -import { - ChangeDetectorRef, - Component, - ComponentRef, - EventEmitter, - Input, - OnChanges, - OnDestroy, - OnInit, - Output, - SimpleChanges, - ViewChild -} from '@angular/core'; - -import { Subscription, combineLatest, of as observableOf, Observable } from 'rxjs'; +import { ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; import { take } from 'rxjs/operators'; - import { ListableObject } from '../listable-object.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { Context } from '../../../../core/shared/context.model'; +import { Context } from 'src/app/core/shared/context.model'; import { getListableObjectComponent } from './listable-object.decorator'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; -import { ListableObjectDirective } from './listable-object.directive'; import { CollectionElementLinkType } from '../../collection-element-link.type'; -import { hasValue, isNotEmpty, hasNoValue } from '../../../empty.util'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; -import { ThemeService } from '../../../theme-support/theme.service'; +import { AbstractComponentLoaderComponent } from '../../../abstract-component-loader/abstract-component-loader.component'; +import { ThemeService } from 'src/app/shared/theme-support/theme.service'; @Component({ selector: 'ds-listable-object-component-loader', styleUrls: ['./listable-object-component-loader.component.scss'], - templateUrl: './listable-object-component-loader.component.html' + templateUrl: '../../../abstract-component-loader/abstract-component-loader.component.html', }) /** * Component for determining what component to use depending on the item's entity type (dspace.entity.type) */ -export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges, OnDestroy { +export class ListableObjectComponentLoaderComponent extends AbstractComponentLoaderComponent { + /** * The item or metadata to determine the component for */ @@ -73,115 +58,60 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges /** * Whether to show the thumbnail preview */ - @Input() showThumbnails; + @Input() showThumbnails: boolean; /** * The value to display for this element */ @Input() value: string; - /** - * Directive hook used to place the dynamic child component - */ - @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective; - /** * Emit when the listable object has been reloaded. */ @Output() contentChange = new EventEmitter(); - /** - * Array to track all subscriptions and unsubscribe them onDestroy - * @type {Array} - */ - protected subs: Subscription[] = []; - - /** - * The reference to the dynamic component - */ - protected compRef: ComponentRef; + protected inputNamesDependentForComponent: (keyof this & string)[] = [ + 'object', + 'viewMode', + 'context', + ]; /** * The list of input and output names for the dynamic component */ - protected inAndOutputNames: string[] = [ + protected inputNames: (keyof this & string)[] = [ 'object', 'index', + 'context', 'linkType', 'listID', 'showLabel', 'showThumbnails', - 'context', 'viewMode', 'value', - 'hideBadges', - 'contentChange', ]; - constructor(private cdr: ChangeDetectorRef, private themeService: ThemeService) { - } - - /** - * Setup the dynamic child component - */ - ngOnInit(): void { - this.instantiateComponent(this.object); - } - - /** - * Whenever the inputs change, update the inputs of the dynamic component - */ - ngOnChanges(changes: SimpleChanges): void { - if (hasNoValue(this.compRef)) { - // sometimes the component has not been initialized yet, so it first needs to be initialized - // before being called again - this.instantiateComponent(this.object, changes); - } else { - // if an input or output has changed - if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) { - this.connectInputsAndOutputs(); - if (this.compRef?.instance && 'ngOnChanges' in this.compRef.instance) { - (this.compRef.instance as any).ngOnChanges(changes); - } - } - } - } + protected outputNames: (keyof this & string)[] = [ + 'contentChange', + ]; - ngOnDestroy() { - this.subs - .filter((subscription) => hasValue(subscription)) - .forEach((subscription) => subscription.unsubscribe()); + constructor( + protected themeService: ThemeService, + protected cdr: ChangeDetectorRef, + ) { + super(themeService); } - private instantiateComponent(object: ListableObject, changes?: SimpleChanges): void { - - const component = this.getComponent(object.getRenderTypes(), this.viewMode, this.context); - - const viewContainerRef = this.listableObjectDirective.viewContainerRef; - viewContainerRef.clear(); - - this.compRef = viewContainerRef.createComponent( - component, { - index: 0, - injector: undefined - } - ); - - if (hasValue(changes)) { - this.ngOnChanges(changes); - } else { - this.connectInputsAndOutputs(); - } - + public instantiateComponent(): void { + super.instantiateComponent(); if ((this.compRef.instance as any).reloadedObject) { - combineLatest([ - observableOf(changes), - (this.compRef.instance as any).reloadedObject.pipe(take(1)) as Observable, - ]).subscribe(([simpleChanges, reloadedObject]: [SimpleChanges, DSpaceObject]) => { + (this.compRef.instance as any).reloadedObject.pipe( + take(1), + ).subscribe((reloadedObject: DSpaceObject) => { if (reloadedObject) { - this.compRef.destroy(); + this.destroyComponentInstance(); this.object = reloadedObject; - this.instantiateComponent(reloadedObject, simpleChanges); + this.instantiateComponent(); this.cdr.detectChanges(); this.contentChange.emit(reloadedObject); } @@ -189,26 +119,8 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges } } - /** - * Fetch the component depending on the item's entity type, view mode and context - * @returns {GenericConstructor} - */ - getComponent(renderTypes: (string | GenericConstructor)[], - viewMode: ViewMode, - context: Context): GenericConstructor { - return getListableObjectComponent(renderTypes, viewMode, context, this.themeService.getThemeName()); - } - - /** - * Connect the in and outputs of this component to the dynamic component, - * to ensure they're in sync - */ - protected connectInputsAndOutputs(): void { - if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { - this.inAndOutputNames.filter((name: any) => this[name] !== undefined).forEach((name: any) => { - this.compRef.instance[name] = this[name]; - }); - } + public getComponent(): GenericConstructor { + return getListableObjectComponent(this.object.getRenderTypes(), this.viewMode, this.context, this.themeService.getThemeName()); } } diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts index 470bcfcdafd..4f1a04a985d 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts @@ -34,7 +34,7 @@ export const DEFAULT_THEME = '*'; * - { level: 1, relevancy: 1 } is less relevant than { level: 2, relevancy: 0 } * - { level: 1, relevancy: 1 } is more relevant than null */ -class MatchRelevancy { +export class MatchRelevancy { constructor(public match: any, public level: number, public relevancy: number) { @@ -133,7 +133,7 @@ export function getListableObjectComponent(types: (string | GenericConstructor, keys: any[], defaults: any[]): MatchRelevancy { +export function getMatch(typeMap: Map, keys: any[], defaults: any[]): MatchRelevancy { let currentMap = typeMap; let level = -1; let relevancy = 0; diff --git a/src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts b/src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts new file mode 100644 index 00000000000..528506cfd1d --- /dev/null +++ b/src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts @@ -0,0 +1,82 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ListableObject } from '../listable-object.model'; +import { CollectionElementLinkType } from '../../collection-element-link.type'; +import { Context } from '../../../../core/shared/context.model'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; + +@Component({ + selector: 'ds-objects-collection-tabulatable', + template: ``, +}) + +/** + * Abstract class that describe the properties for the rendering of search result's paginated lists of objects in a table. + * To be used as descriptor of the actual result component e.g. TabulatableResultListElementsComponent + */ +export class AbstractTabulatableElementComponent> { + + /** + * The object to render in this list element + */ + @Input() objects: T; + + /** + * The link type to determine the type of link rendered in this element + */ + @Input() linkType: CollectionElementLinkType; + + /** + * The identifier of the list this element resides in + */ + @Input() listID: string; + + /** + * The value to display for this element + */ + @Input() value: string; + + /** + * Whether to show the badge label or not + */ + @Input() showLabel = true; + + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + + /** + * The context we matched on to get this component + */ + @Input() context: Context; + + /** + * The viewmode we matched on to get this component + */ + @Input() viewMode: ViewMode; + + /** + * Emit when the object has been reloaded. + */ + @Output() reloadedObject = new EventEmitter>>(); + + /** + * The available link types + */ + linkTypes = CollectionElementLinkType; + + /** + * The available view modes + */ + viewModes = ViewMode; + + /** + * The available contexts + */ + contexts = Context; + + +} + diff --git a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html index 56a83913a76..a90f375c6a1 100644 --- a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html +++ b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html @@ -1,10 +1,12 @@ - { let comp: SelectableListItemControlComponent; @@ -45,7 +46,10 @@ describe('SelectableListItemControlComponent', () => { init(); TestBed.configureTestingModule({ declarations: [SelectableListItemControlComponent, VarDirective], - imports: [FormsModule], + imports: [ + FormsModule, + TranslateModule.forRoot(), + ], providers: [ { provide: SelectableListService, diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html new file mode 100644 index 00000000000..ff51746cb9a --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts new file mode 100644 index 00000000000..e500bbe9f3b --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts @@ -0,0 +1,107 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { TabulatableObjectsLoaderComponent } from './tabulatable-objects-loader.component'; +import { ThemeService } from '../../../theme-support/theme.service'; +import { provideMockStore } from '@ngrx/store/testing'; +import { ListableObject } from '../listable-object.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { Context } from '../../../../core/shared/context.model'; +import { TabulatableObjectsDirective } from './tabulatable-objects.directive'; +import { ChangeDetectionStrategy } from '@angular/core'; + + +import { + TabulatableResultListElementsComponent +} from '../../../object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component'; +import { TestType } from '../listable-object/listable-object-component-loader.component.spec'; +import { By } from '@angular/platform-browser'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; + + +const testType = 'TestType'; +const testContext = Context.CoarNotify; +const testViewMode = ViewMode.Table; + +class TestTypes extends PaginatedList { + page: TestType[] = [new TestType()]; +} + + +describe('TabulatableObjectsLoaderComponent', () => { + let component: TabulatableObjectsLoaderComponent; + let fixture: ComponentFixture; + + let themeService: ThemeService; + + beforeEach(async () => { + themeService = jasmine.createSpyObj('themeService', { + getThemeName: 'dspace', + }); + await TestBed.configureTestingModule({ + declarations: [ TabulatableObjectsLoaderComponent, TabulatableObjectsDirective ], + providers: [ + provideMockStore({}), + { provide: ThemeService, useValue: themeService }, + ] + }).overrideComponent(TabulatableObjectsLoaderComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + entryComponents: [TabulatableResultListElementsComponent] + } + }).compileComponents(); + + fixture = TestBed.createComponent(TabulatableObjectsLoaderComponent); + component = fixture.componentInstance; + component.objects = new TestTypes(); + component.context = Context.CoarNotify; + spyOn(component, 'getComponent').and.returnValue(TabulatableResultListElementsComponent as any); + spyOn(component as any, 'connectInputsAndOutputs').and.callThrough(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('When the component is rendered', () => { + it('should call the getTabulatableObjectComponent function with the right types, view mode and context', () => { + expect(component.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); + }); + + it('should connectInputsAndOutputs of loaded component', () => { + expect((component as any).connectInputsAndOutputs).toHaveBeenCalled(); + }); + }); + + describe('When a reloadedObject is emitted', () => { + let tabulatableComponent; + let reloadedObject: any; + + beforeEach(() => { + spyOn((component as any), 'instantiateComponent').and.returnValue(null); + spyOn((component as any).contentChange, 'emit').and.returnValue(null); + + tabulatableComponent = fixture.debugElement.query(By.css('ds-search-result-table-element')).componentInstance; + reloadedObject = 'object'; + }); + + it('should re-instantiate the listable component', fakeAsync(() => { + expect((component as any).instantiateComponent).not.toHaveBeenCalled(); + + (tabulatableComponent as any).reloadedObject.emit(reloadedObject); + tick(200); + + expect((component as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject, undefined); + })); + + it('should re-emit it as a contentChange', fakeAsync(() => { + expect((component as any).contentChange.emit).not.toHaveBeenCalled(); + + (tabulatableComponent as any).reloadedObject.emit(reloadedObject); + tick(200); + + expect((component as any).contentChange.emit).toHaveBeenCalledWith(reloadedObject); + })); + + }); +}); diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts new file mode 100644 index 00000000000..a8e3af6001c --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts @@ -0,0 +1,208 @@ +import { + ChangeDetectorRef, + Component, + ComponentRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { ListableObject } from '../listable-object.model'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { Context } from '../../../../core/shared/context.model'; +import { CollectionElementLinkType } from '../../collection-element-link.type'; +import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; +import { ThemeService } from '../../../theme-support/theme.service'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util'; +import { take } from 'rxjs/operators'; +import { GenericConstructor } from '../../../../core/shared/generic-constructor'; +import { TabulatableObjectsDirective } from './tabulatable-objects.directive'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { getTabulatableObjectsComponent } from './tabulatable-objects.decorator'; + +@Component({ + selector: 'ds-tabulatable-objects-loader', + templateUrl: './tabulatable-objects-loader.component.html' +}) +/** + * Component to load the matching component flagged by the tabulatableObjectsComponent decorator. + * Each component flagged by the decorator needs to have a ViewMode set as Table in order to be matched by the loader. + * e.g. @tabulatableObjectsComponent(PaginatedList, ViewMode.Table, Context.CoarNotify) + */ +export class TabulatableObjectsLoaderComponent implements OnInit, OnChanges, OnDestroy { + /** + * The items to determine the component for + */ + @Input() objects: PaginatedList; + + + /** + * The context of tabulatable object + */ + @Input() context: Context; + + /** + * The type of link used to render the links inside the listable object + */ + @Input() linkType: CollectionElementLinkType; + + /** + * The identifier of the list this element resides in + */ + @Input() listID: string; + + /** + * Whether to show the badge label or not + */ + @Input() showLabel = true; + + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + + /** + * The value to display for this element + */ + @Input() value: string; + + /** + * Directive hook used to place the dynamic child component + */ + @ViewChild(TabulatableObjectsDirective, { static: true }) tabulatableObjectsDirective: TabulatableObjectsDirective; + + /** + * Emit when the listable object has been reloaded. + */ + @Output() contentChange = new EventEmitter>(); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * The reference to the dynamic component + */ + protected compRef: ComponentRef; + + /** + * The view mode used to identify the components + */ + protected viewMode: ViewMode = ViewMode.Table; + + /** + * The list of input and output names for the dynamic component + */ + protected inAndOutputNames: string[] = [ + 'objects', + 'linkType', + 'listID', + 'showLabel', + 'showThumbnails', + 'context', + 'viewMode', + 'value', + 'hideBadges', + 'contentChange', + ]; + + constructor(private cdr: ChangeDetectorRef, private themeService: ThemeService) { + } + + /** + * Setup the dynamic child component + */ + ngOnInit(): void { + this.instantiateComponent(this.objects); + } + + /** + * Whenever the inputs change, update the inputs of the dynamic component + */ + ngOnChanges(changes: SimpleChanges): void { + if (hasNoValue(this.compRef)) { + // sometimes the component has not been initialized yet, so it first needs to be initialized + // before being called again + this.instantiateComponent(this.objects, changes); + } else { + // if an input or output has changed + if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) { + this.connectInputsAndOutputs(); + if (this.compRef?.instance && 'ngOnChanges' in this.compRef.instance) { + (this.compRef.instance as any).ngOnChanges(changes); + } + } + } + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + private instantiateComponent(objects: PaginatedList, changes?: SimpleChanges): void { + // objects need to have same render type so we access just the first in the page + const component = this.getComponent(objects?.page[0]?.getRenderTypes(), this.viewMode, this.context); + + const viewContainerRef = this.tabulatableObjectsDirective.viewContainerRef; + viewContainerRef.clear(); + + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined + } + ); + + if (hasValue(changes)) { + this.ngOnChanges(changes); + } else { + this.connectInputsAndOutputs(); + } + + if ((this.compRef.instance as any).reloadedObject) { + combineLatest([ + observableOf(changes), + (this.compRef.instance as any).reloadedObject.pipe(take(1)) as Observable>, + ]).subscribe(([simpleChanges, reloadedObjects]: [SimpleChanges, PaginatedList]) => { + if (reloadedObjects) { + this.compRef.destroy(); + this.objects = reloadedObjects; + this.instantiateComponent(reloadedObjects, simpleChanges); + this.cdr.detectChanges(); + this.contentChange.emit(reloadedObjects); + } + }); + } + } + + /** + * Fetch the component depending on the item's entity type, view mode and context + * @returns {GenericConstructor} + */ + getComponent(renderTypes: (string | GenericConstructor)[], + viewMode: ViewMode, + context: Context): GenericConstructor { + return getTabulatableObjectsComponent(renderTypes, viewMode, context, this.themeService.getThemeName()); + } + + /** + * Connect the in and outputs of this component to the dynamic component, + * to ensure they're in sync + */ + protected connectInputsAndOutputs(): void { + if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { + this.inAndOutputNames.filter((name: any) => this[name] !== undefined).forEach((name: any) => { + this.compRef.instance[name] = this[name]; + }); + } + } + +} diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.spec.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.spec.ts new file mode 100644 index 00000000000..f33ed2e49f1 --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.spec.ts @@ -0,0 +1,23 @@ +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { getTabulatableObjectsComponent, tabulatableObjectsComponent } from './tabulatable-objects.decorator'; +import { Context } from '../../../../core/shared/context.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; + +const type = 'TestType'; + +@tabulatableObjectsComponent(PaginatedList, ViewMode.Table, Context.Search) +class TestTable { +} +describe('TabulatableObject decorator function', () => { + + it('should have a decorator for table', () => { + const tableDecorator = tabulatableObjectsComponent('Item', ViewMode.Table); + expect(tableDecorator.length).not.toBeNull(); + }); + + + it('should return the matching class', () => { + const component = getTabulatableObjectsComponent([type], ViewMode.Table, Context.Search); + expect(component).toEqual(TestTable); + }); +}); diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts new file mode 100644 index 00000000000..caa4576df19 --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts @@ -0,0 +1,66 @@ +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { Context } from '../../../../core/shared/context.model'; +import { hasNoValue, hasValue } from '../../../empty.util'; +import { GenericConstructor } from '../../../../core/shared/generic-constructor'; +import { ListableObject } from '../listable-object.model'; +import { + DEFAULT_CONTEXT, + DEFAULT_THEME, + DEFAULT_VIEW_MODE, getMatch, + MatchRelevancy +} from '../listable-object/listable-object.decorator'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; + + +const map = new Map(); + +/** + * Decorator used for rendering tabulatable objects + * @param objectsType The object type or entity type the component represents + * @param viewMode The view mode the component represents + * @param context The optional context the component represents + * @param theme The optional theme for the component + */ +export function tabulatableObjectsComponent(objectsType: string | GenericConstructor>, viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) { + return function decorator(component: any) { + if (hasNoValue(objectsType)) { + return; + } + if (hasNoValue(map.get(objectsType))) { + map.set(objectsType, new Map()); + } + if (hasNoValue(map.get(objectsType).get(viewMode))) { + map.get(objectsType).set(viewMode, new Map()); + } + if (hasNoValue(map.get(objectsType).get(viewMode).get(context))) { + map.get(objectsType).get(viewMode).set(context, new Map()); + } + map.get(objectsType).get(viewMode).get(context).set(theme, component); + }; +} + +/** + * Getter to retrieve the matching tabulatable objects component + * + * Looping over the provided types, it'll attempt to find the best match depending on the {@link MatchRelevancy} returned by getMatch() + * The most relevant match between types is kept and eventually returned + * + * @param types The types of which one should match the tabulatable component + * @param viewMode The view mode that should match the components + * @param context The context that should match the components + * @param theme The theme that should match the components + */ +export function getTabulatableObjectsComponent(types: (string | GenericConstructor)[], viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme: string = DEFAULT_THEME) { + let currentBestMatch: MatchRelevancy = null; + for (const type of types) { + const typeMap = map.get(PaginatedList); + + if (hasValue(typeMap)) { + const match = getMatch(typeMap, [viewMode, context, theme], [DEFAULT_VIEW_MODE, DEFAULT_CONTEXT, DEFAULT_THEME]); + if (hasNoValue(currentBestMatch) || currentBestMatch.isLessRelevantThan(match)) { + currentBestMatch = match; + } + } + } + return hasValue(currentBestMatch) ? currentBestMatch.match : null; +} diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts new file mode 100644 index 00000000000..88c12dfe763 --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts @@ -0,0 +1,11 @@ +import { Directive, ViewContainerRef } from '@angular/core'; + +@Directive({ + selector: '[dsTabulatableObjects]', +}) +/** + * Directive used as a hook to know where to inject the dynamic listable object component + */ +export class TabulatableObjectsDirective { + constructor(public viewContainerRef: ViewContainerRef) { } +} diff --git a/src/app/shared/object-list/duplicate-data/duplicate.model.ts b/src/app/shared/object-list/duplicate-data/duplicate.model.ts new file mode 100644 index 00000000000..a165b81bab4 --- /dev/null +++ b/src/app/shared/object-list/duplicate-data/duplicate.model.ts @@ -0,0 +1,57 @@ +import {autoserialize, deserialize} from 'cerialize'; +import { MetadataMap } from '../../../core/shared/metadata.models'; +import { HALLink} from '../../../core/shared/hal-link.model'; +import { CacheableObject } from '../../../core/cache/cacheable-object.model'; +import { DUPLICATE } from './duplicate.resource-type'; +import { ResourceType } from '../../../core/shared/resource-type'; + +/** + * This implements the model of a duplicate preview stub, to be displayed to submitters or reviewers + * if duplicate detection is enabled. The metadata map is configurable in the backend at duplicate-detection.cfg + */ +export class Duplicate implements CacheableObject { + + static type = DUPLICATE; + + /** + * The item title + */ + @autoserialize + title: string; + /** + * The item uuid + */ + @autoserialize + uuid: string; + /** + * The workfow item ID, if any + */ + @autoserialize + workflowItemId: number; + /** + * The workspace item ID, if any + */ + @autoserialize + workspaceItemId: number; + /** + * The owning collection of the item + */ + @autoserialize + owningCollection: string; + /** + * Metadata for the preview item (e.g. dc.title) + */ + @autoserialize + metadata: MetadataMap; + + @autoserialize + type: ResourceType; + + /** + * The {@link HALLink}s for the URL that generated this item (in context of search results) + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts b/src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts new file mode 100644 index 00000000000..588ca2da55e --- /dev/null +++ b/src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from 'src/app/core/shared/resource-type'; + +/** + * The resource type for Duplicate preview stubs + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const DUPLICATE = new ResourceType('duplicate'); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html index 42af008cdd7..9cdeb49e5c6 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html @@ -4,7 +4,17 @@ [showSubmitter]="showSubmitter" [badgeContext]="badgeContext" [workflowItem]="workflowitem$.value"> - + + +
+
+
+
+ {{ duplicateCount }} {{ 'submission.workflow.tasks.duplicates' | translate }} +
+
+
+
; @@ -35,6 +39,21 @@ let fixture: ComponentFixture; const mockResultObject: ClaimedTaskSearchResult = new ClaimedTaskSearchResult(); mockResultObject.hitHighlights = {}; +const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); + +const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'duplicate.enable', + values: [ + 'true' + ] + })) +}); +const duplicateDataServiceStub = { + findListByHref: () => observableOf(emptyList), + findDuplicates: () => createSuccessfulRemoteDataObject$({}), +}; + const item = Object.assign(new Item(), { bundles: observableOf({}), metadata: { @@ -83,7 +102,9 @@ describe('ClaimedSearchResultListElementComponent', () => { { provide: LinkService, useValue: linkService }, { provide: DSONameService, useClass: DSONameServiceMock }, { provide: APP_CONFIG, useValue: environment }, - { provide: ObjectCacheService, useValue: objectCacheServiceMock } + { provide: ObjectCacheService, useValue: objectCacheServiceMock }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SubmissionDuplicateDataService, useValue: duplicateDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedSearchResultListElementComponent, { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts index 18148b6a8c4..2dd87ec1a1a 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts @@ -5,7 +5,7 @@ import { listableObjectComponent } from '../../../object-collection/shared/lista import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { TruncatableService } from '../../../truncatable/truncatable.service'; -import { BehaviorSubject, EMPTY, Observable } from 'rxjs'; +import {BehaviorSubject, combineLatest, EMPTY, Observable} from 'rxjs'; import { RemoteData } from '../../../../core/data/remote-data'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { followLink } from '../../../utils/follow-link-config.model'; @@ -18,9 +18,14 @@ import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interfac import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { Item } from '../../../../core/shared/item.model'; -import { mergeMap, tap } from 'rxjs/operators'; +import { map, mergeMap, tap } from 'rxjs/operators'; import { isNotEmpty, hasValue } from '../../../empty.util'; import { Context } from '../../../../core/shared/context.model'; +import { Duplicate } from '../../duplicate-data/duplicate.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { SubmissionDuplicateDataService } from '../../../../core/submission/submission-duplicate-data.service'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; @Component({ selector: 'ds-claimed-search-result-list-element', @@ -50,6 +55,11 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle */ public workflowitem$: BehaviorSubject = new BehaviorSubject(null); + /** + * The potential duplicates of this item + */ + public duplicates$: Observable; + /** * Display thumbnails if required by configuration */ @@ -60,6 +70,8 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle protected truncatableService: TruncatableService, public dsoNameService: DSONameService, protected objectCache: ObjectCacheService, + protected configService: ConfigurationDataService, + protected duplicateDataService: SubmissionDuplicateDataService, @Inject(APP_CONFIG) protected appConfig: AppConfig ) { super(truncatableService, dsoNameService, appConfig); @@ -93,8 +105,43 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle } }) ).subscribe(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; + // Initialise duplicates, if enabled + this.duplicates$ = this.initializeDuplicateDetectionIfEnabled(); + } + + /** + * Initialize and set the duplicates observable based on whether the configuration in REST is enabled + * and the results returned + */ + initializeDuplicateDetectionIfEnabled() { + return combineLatest([ + this.configService.findByPropertyName('duplicate.enable').pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData) => { + return (remoteData.isSuccess && remoteData.payload && remoteData.payload.values[0] === 'true'); + }) + ), + this.item$.pipe(), + ] + ).pipe( + map(([enabled, rd]) => { + if (enabled) { + this.duplicates$ = this.duplicateDataService.findDuplicates(rd.uuid).pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData>) => { + if (remoteData.hasSucceeded) { + if (remoteData.payload.page) { + return remoteData.payload.page; + } + } + }) + ); + } else { + return [] as Duplicate[]; + } + }), + ); } ngOnDestroy() { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html index 9fe6e37c9e3..ab7d7a7d8a3 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html @@ -4,6 +4,19 @@ [showSubmitter]="showSubmitter" [badgeContext]="badgeContext" [workflowItem]="workflowitem$.value"> + + + +
+
+
+
+ {{ duplicateCount }} {{ 'submission.workflow.tasks.duplicates' | translate }} +
+
+
+
+
; @@ -34,7 +38,23 @@ let fixture: ComponentFixture; const mockResultObject: PoolTaskSearchResult = new PoolTaskSearchResult(); mockResultObject.hitHighlights = {}; +const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); + +const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'duplicate.enable', + values: [ + 'true' + ] + })) +}); +const duplicateDataServiceStub = { + findListByHref: () => observableOf(emptyList), + findDuplicates: () => createSuccessfulRemoteDataObject$({}), +}; + const item = Object.assign(new Item(), { + duplicates: observableOf([]), bundles: observableOf({}), metadata: { 'dc.title': [ @@ -89,7 +109,9 @@ describe('PoolSearchResultListElementComponent', () => { { provide: LinkService, useValue: linkService }, { provide: DSONameService, useClass: DSONameServiceMock }, { provide: APP_CONFIG, useValue: environmentUseThumbs }, - { provide: ObjectCacheService, useValue: objectCacheServiceMock } + { provide: ObjectCacheService, useValue: objectCacheServiceMock }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SubmissionDuplicateDataService, useValue: duplicateDataServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PoolSearchResultListElementComponent, { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts index 19723a7e494..087f234fd36 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts @@ -1,7 +1,7 @@ import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; -import { BehaviorSubject, EMPTY, Observable } from 'rxjs'; -import { mergeMap, tap } from 'rxjs/operators'; +import {BehaviorSubject, combineLatest, EMPTY, Observable } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -22,6 +22,11 @@ import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { Item } from '../../../../core/shared/item.model'; import { isNotEmpty, hasValue } from '../../../empty.util'; import { Context } from '../../../../core/shared/context.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { Duplicate } from '../../duplicate-data/duplicate.model'; +import { SubmissionDuplicateDataService } from '../../../../core/submission/submission-duplicate-data.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; /** * This component renders pool task object for the search result in the list view. @@ -55,6 +60,11 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen */ public workflowitem$: BehaviorSubject = new BehaviorSubject(null); + /** + * The potential duplicates of this workflow item + */ + public duplicates$: Observable; + /** * The index of this list element */ @@ -70,6 +80,8 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen protected truncatableService: TruncatableService, public dsoNameService: DSONameService, protected objectCache: ObjectCacheService, + protected configService: ConfigurationDataService, + protected duplicateDataService: SubmissionDuplicateDataService, @Inject(APP_CONFIG) protected appConfig: AppConfig ) { super(truncatableService, dsoNameService, appConfig); @@ -101,10 +113,45 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen if (isNotEmpty(itemRD) && itemRD.hasSucceeded) { this.item$.next(itemRD.payload); } - }) + }), ).subscribe(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; + // Initialise duplicates, if enabled + this.duplicates$ = this.initializeDuplicateDetectionIfEnabled(); + } + + /** + * Initialize and set the duplicates observable based on whether the configuration in REST is enabled + * and the results returned + */ + initializeDuplicateDetectionIfEnabled() { + return combineLatest([ + this.configService.findByPropertyName('duplicate.enable').pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData) => { + return (remoteData.isSuccess && remoteData.payload && remoteData.payload.values[0] === 'true'); + }) + ), + this.item$.pipe(), + ] + ).pipe( + map(([enabled, rd]) => { + if (enabled) { + this.duplicates$ = this.duplicateDataService.findDuplicates(rd.uuid).pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData>) => { + if (remoteData.hasSucceeded) { + if (remoteData.payload.page) { + return remoteData.payload.page; + } + } + }) + ); + } else { + return [] as Duplicate[]; + } + }), + ); } ngOnDestroy() { diff --git a/src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts b/src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts new file mode 100644 index 00000000000..70018e8689b --- /dev/null +++ b/src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { + AbstractTabulatableElementComponent +} from '../../../object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { SearchResult } from '../../../search/models/search-result.model'; + +@Component({ + selector: 'ds-search-result-table-element', + template: `` +}) +/** + * Component that describes the implementations and interfaces needed from any extension of this class to be used in search results for visualization in ViewMode.Table + */ +export class TabulatableResultListElementsComponent, K extends SearchResult> extends AbstractTabulatableElementComponent {} diff --git a/src/app/shared/object-select/collection-select/collection-select.component.html b/src/app/shared/object-select/collection-select/collection-select.component.html index 9b87f69c040..135579aff7b 100644 --- a/src/app/shared/object-select/collection-select/collection-select.component.html +++ b/src/app/shared/object-select/collection-select/collection-select.component.html @@ -11,13 +11,13 @@ - + - + diff --git a/src/app/shared/object-select/item-select/item-select.component.html b/src/app/shared/object-select/item-select/item-select.component.html index 7f8ff943a37..d1d454b2e43 100644 --- a/src/app/shared/object-select/item-select/item-select.component.html +++ b/src/app/shared/object-select/item-select/item-select.component.html @@ -11,7 +11,7 @@
{{'collection.select.table.selected' | translate}} {{'collection.select.table.title' | translate}}
{{ dsoNameService.getName(collection) }}
- + @@ -19,7 +19,7 @@ - +
{{'item.select.table.selected' | translate}} {{'item.select.table.collection' | translate}} {{'item.select.table.author' | translate}} {{'item.select.table.title' | translate}}
diff --git a/src/app/shared/object-table/object-table.component.html b/src/app/shared/object-table/object-table.component.html new file mode 100644 index 00000000000..39743a5922c --- /dev/null +++ b/src/app/shared/object-table/object-table.component.html @@ -0,0 +1,33 @@ + +
+
+ + +
+
+ + +
+ + diff --git a/src/app/shared/object-table/object-table.component.scss b/src/app/shared/object-table/object-table.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/object-table/object-table.component.spec.ts b/src/app/shared/object-table/object-table.component.spec.ts new file mode 100644 index 00000000000..bfd3c769cfd --- /dev/null +++ b/src/app/shared/object-table/object-table.component.spec.ts @@ -0,0 +1,152 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { By } from '@angular/platform-browser'; +import { ObjectTableComponent } from './object-table.component'; + +describe('ObjectTableComponent', () => { + let component: ObjectTableComponent; + let fixture: ComponentFixture; + const testEvent: any = { test: 'test' }; + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ObjectTableComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ObjectTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + + describe('when the pageChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(component, 'onPageChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('pageChange', testEvent); + }); + + it('should call onPageChange on the componentonent', () => { + expect(component.onPageChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the pageSizeChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(component, 'onPageSizeChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('pageSizeChange', testEvent); + }); + + it('should call onPageSizeChange on the componentonent', () => { + expect(component.onPageSizeChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the sortDirectionChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(component, 'onSortDirectionChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('sortDirectionChange', testEvent); + }); + + it('should call onSortDirectionChange on the componentonent', () => { + expect(component.onSortDirectionChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the sortFieldChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(component, 'onSortFieldChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('sortFieldChange', testEvent); + }); + + it('should call onSortFieldChange on the componentonent', () => { + expect(component.onSortFieldChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the paginationChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(component, 'onPaginationChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('paginationChange', testEvent); + }); + + it('should call onPaginationChange on the componentonent', () => { + expect(component.onPaginationChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when onPageChange is triggered with an event', () => { + beforeEach(() => { + spyOn(component.pageChange, 'emit'); + component.onPageChange(testEvent); + }); + + it('should emit the value from the pageChange EventEmitter', fakeAsync(() => { + tick(1); + expect(component.pageChange.emit).toHaveBeenCalled(); + expect(component.pageChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onPageSizeChange is triggered with an event', () => { + beforeEach(() => { + spyOn(component.pageSizeChange, 'emit'); + component.onPageSizeChange(testEvent); + }); + + it('should emit the value from the pageSizeChange EventEmitter', fakeAsync(() => { + tick(1); + expect(component.pageSizeChange.emit).toHaveBeenCalled(); + expect(component.pageSizeChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onSortDirectionChange is triggered with an event', () => { + beforeEach(() => { + spyOn(component.sortDirectionChange, 'emit'); + component.onSortDirectionChange(testEvent); + }); + + it('should emit the value from the sortDirectionChange EventEmitter', fakeAsync(() => { + tick(1); + expect(component.sortDirectionChange.emit).toHaveBeenCalled(); + expect(component.sortDirectionChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onSortFieldChange is triggered with an event', () => { + beforeEach(() => { + spyOn(component.sortFieldChange, 'emit'); + component.onSortFieldChange(testEvent); + }); + + it('should emit the value from the sortFieldChange EventEmitter', fakeAsync(() => { + tick(1); + expect(component.sortFieldChange.emit).toHaveBeenCalled(); + expect(component.sortFieldChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onPaginationChange is triggered with an event', () => { + beforeEach(() => { + spyOn(component.paginationChange, 'emit'); + component.onPaginationChange(testEvent); + }); + + it('should emit the value from the paginationChange EventEmitter', fakeAsync(() => { + tick(1); + expect(component.paginationChange.emit).toHaveBeenCalled(); + expect(component.paginationChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); +}); diff --git a/src/app/shared/object-table/object-table.component.ts b/src/app/shared/object-table/object-table.component.ts new file mode 100644 index 00000000000..510cff4e66e --- /dev/null +++ b/src/app/shared/object-table/object-table.component.ts @@ -0,0 +1,206 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { ViewMode } from '../../core/shared/view-mode.model'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { CollectionElementLinkType } from '../object-collection/collection-element-link.type'; +import { Context } from '../../core/shared/context.model'; +import { BehaviorSubject} from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; +import { fadeIn } from '../animations/fade'; + + +@Component({ + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.Emulated, + selector: 'ds-object-table', + templateUrl: './object-table.component.html', + styleUrls: ['./object-table.component.scss'], + animations: [fadeIn] +}) + +/** + * Component used to wrap and load paginated search results if the ViewMode is set to Table. + * Each ViewMode has a different type of wrapper that can be checked in ObjectCollectionComponent + */ +export class ObjectTableComponent { + /** + * The view mode of this component + */ + viewMode = ViewMode.Table; + + /** + * The current pagination configuration + */ + @Input() config: PaginationComponentOptions; + + /** + * The current sort configuration + */ + @Input() sortConfig: SortOptions; + + /** + * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination + */ + @Input() showPaginator = true; + + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + + /** + * The whether or not the gear is hidden + */ + @Input() hideGear = false; + + /** + * Whether or not the pager is visible when there is only a single page of results + */ + @Input() hidePagerWhenSinglePage = true; + + /** + * The link type of the listable elements + */ + @Input() linkType: CollectionElementLinkType; + + /** + * The context of the listable elements + */ + @Input() context: Context; + + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + + /** + * Behavior subject to output the current listable objects + */ + private _objects$: BehaviorSubject>>; + + /** + * Setter to make sure the observable is turned into an observable + * @param objects The new objects to output + */ + @Input() set objects(objects: RemoteData>) { + this._objects$.next(objects); + } + + /** + * Getter to return the current objects + */ + get objects() { + return this._objects$.getValue(); + } + + /** + * An event fired when the page is changed. + * Event's payload equals to the newly selected page. + */ + @Output() change: EventEmitter<{ + pagination: PaginationComponentOptions, + sort: SortOptions + }> = new EventEmitter<{ + pagination: PaginationComponentOptions, + sort: SortOptions + }>(); + + /** + * An event fired when the page is changed. + * Event's payload equals to the newly selected page. + */ + @Output() pageChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when the page wsize is changed. + * Event's payload equals to the newly selected page size. + */ + @Output() pageSizeChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when the sort direction is changed. + * Event's payload equals to the newly selected sort direction. + */ + @Output() sortDirectionChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when on of the pagination parameters changes + */ + @Output() paginationChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when the sort field is changed. + * Event's payload equals to the newly selected sort field. + */ + @Output() sortFieldChange: EventEmitter = new EventEmitter(); + + /** + * If showPaginator is set to true, emit when the previous button is clicked + */ + @Output() prev = new EventEmitter(); + + /** + * If showPaginator is set to true, emit when the next button is clicked + */ + @Output() next = new EventEmitter(); + + data: any = {}; + + constructor() { + this._objects$ = new BehaviorSubject(undefined); + } + + /** + * Emits the current page when it changes + * @param event The new page + */ + onPageChange(event) { + this.pageChange.emit(event); + } + /** + * Emits the current page size when it changes + * @param event The new page size + */ + onPageSizeChange(event) { + this.pageSizeChange.emit(event); + } + /** + * Emits the current sort direction when it changes + * @param event The new sort direction + */ + onSortDirectionChange(event) { + this.sortDirectionChange.emit(event); + } + + /** + * Emits the current sort field when it changes + * @param event The new sort field + */ + onSortFieldChange(event) { + this.sortFieldChange.emit(event); + } + + /** + * Emits the current pagination when it changes + * @param event The new pagination + */ + onPaginationChange(event) { + this.paginationChange.emit(event); + } + + /** + * Go to the previous page + */ + goPrev() { + this.prev.emit(true); + } + + /** + * Go to the next page + */ + goNext() { + this.next.emit(true); + } +} diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index 6da813cbc7d..1bfc1dfa594 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -122,6 +122,11 @@ export class PaginationComponent implements OnDestroy, OnInit { */ @Input() public hideGear = false; + /** + * Option for hiding the gear + */ + @Input() public hideSortOptions = false; + /** * Option for hiding the pager when there is less than 2 pages */ diff --git a/src/app/shared/selector.util.ts b/src/app/shared/selector.util.ts new file mode 100644 index 00000000000..7ea73347b7c --- /dev/null +++ b/src/app/shared/selector.util.ts @@ -0,0 +1,27 @@ +import { createSelector, MemoizedSelector } from '@ngrx/store'; +import { hasValue } from './empty.util'; + +/** + * Export a function to return a subset of the state by key + */ +export function keySelector(parentSelector, subState: string, key: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState][key]; + } else { + return undefined; + } + }); +} +/** + * Export a function to return a subset of the state + */ +export function subStateSelector(parentSelector, subState: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState]; + } else { + return undefined; + } + }); +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 9f05b1d3706..e360914d6e5 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -282,8 +282,28 @@ import { } from '../item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { BitstreamListItemComponent } from './object-list/bitstream-list-item/bitstream-list-item.component'; import { NgxPaginationModule } from 'ngx-pagination'; +import { SplitPipe } from './utils/split.pipe'; +import { ThemedUserMenuComponent } from './auth-nav-menu/user-menu/themed-user-menu.component'; import { ThemedLangSwitchComponent } from './lang-switch/themed-lang-switch.component'; -import {ThemedUserMenuComponent} from './auth-nav-menu/user-menu/themed-user-menu.component'; +import { QualityAssuranceEventDataService } from '../core/notifications/qa/events/quality-assurance-event-data.service'; +import { QualityAssuranceSourceDataService } from '../core/notifications/qa/source/quality-assurance-source-data.service'; +import { DynamicComponentLoaderDirective } from './abstract-component-loader/dynamic-component-loader.directive'; +import { StartsWithLoaderComponent } from './starts-with/starts-with-loader.component'; +import { IpV4Validator } from './utils/ipV4.validator'; +import { ObjectTableComponent } from './object-table/object-table.component'; +import { + AbstractTabulatableElementComponent +} from './object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component'; +import { + TabulatableObjectsLoaderComponent +} from './object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component'; +import { + TabulatableResultListElementsComponent +} from './object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component'; +import { + TabulatableObjectsDirective +} from './object-collection/shared/tabulatable-objects/tabulatable-objects.directive'; +import { NotificationBoxComponent } from './notification-box/notification-box.component'; const MODULES = [ CommonModule, @@ -323,7 +343,8 @@ const PIPES = [ ObjNgFor, BrowserOnlyPipe, MarkdownPipe, - ShortNumberPipe + ShortNumberPipe, + SplitPipe ]; const COMPONENTS = [ @@ -346,6 +367,7 @@ const COMPONENTS = [ ThemedObjectListComponent, ObjectDetailComponent, ObjectGridComponent, + ObjectTableComponent, AbstractListableElementComponent, ObjectCollectionComponent, PaginationComponent, @@ -379,7 +401,7 @@ const COMPONENTS = [ ThemedStatusBadgeComponent, BadgesComponent, ThemedBadgesComponent, - + StartsWithLoaderComponent, ItemSelectComponent, CollectionSelectComponent, MetadataRepresentationLoaderComponent, @@ -412,6 +434,7 @@ const ENTRY_COMPONENTS = [ CollectionListElementComponent, CommunityListElementComponent, SearchResultListElementComponent, + TabulatableResultListElementsComponent, CommunitySearchResultListElementComponent, CollectionSearchResultListElementComponent, CollectionGridElementComponent, @@ -467,13 +490,17 @@ const ENTRY_COMPONENTS = [ AdvancedClaimedTaskActionRatingComponent, EpersonGroupListComponent, EpersonSearchBoxComponent, - GroupSearchBoxComponent + GroupSearchBoxComponent, + NotificationBoxComponent, + TabulatableObjectsLoaderComponent, ]; const PROVIDERS = [ TruncatableService, MockAdminGuard, - AbstractTrackableComponent + AbstractTrackableComponent, + QualityAssuranceEventDataService, + QualityAssuranceSourceDataService ]; const DIRECTIVES = [ @@ -493,6 +520,9 @@ const DIRECTIVES = [ MetadataFieldValidator, HoverClassDirective, ContextHelpDirective, + DynamicComponentLoaderDirective, + IpV4Validator, + TabulatableObjectsDirective ]; @NgModule({ diff --git a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts index 2407f21fdf3..4c26105890b 100644 --- a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -3,9 +3,8 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { of as observableOf } from 'rxjs'; import { By } from '@angular/platform-browser'; import { StartsWithDateComponent } from './starts-with-date.component'; import { ActivatedRouteStub } from '../../testing/active-router.stub'; @@ -17,29 +16,25 @@ import { PaginationServiceStub } from '../../testing/pagination-service.stub'; describe('StartsWithDateComponent', () => { let comp: StartsWithDateComponent; let fixture: ComponentFixture; - let route: ActivatedRoute; - let router: Router; - let paginationService; - const options = [2019, 2018, 2017, 2016, 2015]; + let route: ActivatedRouteStub; + let paginationService: PaginationServiceStub; + let router: RouterStub; - const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}), - queryParams: observableOf({}) - }); + const options = [2019, 2018, 2017, 2016, 2015]; - paginationService = new PaginationServiceStub(); + beforeEach(waitForAsync(async () => { + route = new ActivatedRouteStub(); + router = new RouterStub(); + paginationService = new PaginationServiceStub(); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + await TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [StartsWithDateComponent, EnumKeysPipe], providers: [ - { provide: 'startsWithOptions', useValue: options }, - { provide: 'paginationId', useValue: 'page-id' }, - { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ActivatedRoute, useValue: route }, { provide: PaginationService, useValue: paginationService }, - { provide: Router, useValue: new RouterStub() } + { provide: Router, useValue: router }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -48,9 +43,9 @@ describe('StartsWithDateComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(StartsWithDateComponent); comp = fixture.componentInstance; + comp.paginationId = 'page-id'; + comp.startsWithOptions = options; fixture.detectChanges(); - route = (comp as any).route; - router = (comp as any).router; }); it('should create a FormGroup containing a startsWith FormControl', () => { @@ -156,10 +151,6 @@ describe('StartsWithDateComponent', () => { describe('when filling in the input form', () => { let form; const expectedValue = '2015'; - const extras: NavigationExtras = { - queryParams: Object.assign({ startsWith: expectedValue }), - queryParamsHandling: 'merge' - }; beforeEach(() => { form = fixture.debugElement.query(By.css('form')); diff --git a/src/app/shared/starts-with/date/starts-with-date.component.ts b/src/app/shared/starts-with/date/starts-with-date.component.ts index 89d9361b6a0..34eda812aee 100644 --- a/src/app/shared/starts-with/date/starts-with-date.component.ts +++ b/src/app/shared/starts-with/date/starts-with-date.component.ts @@ -1,10 +1,7 @@ -import { Component, Inject } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - +import { Component, OnInit } from '@angular/core'; import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; import { hasValue } from '../../empty.util'; -import { PaginationService } from '../../../core/pagination/pagination.service'; /** * A switchable component rendering StartsWith options for the type "Date". @@ -16,7 +13,7 @@ import { PaginationService } from '../../../core/pagination/pagination.service'; templateUrl: './starts-with-date.component.html' }) @renderStartsWithFor(StartsWithType.date) -export class StartsWithDateComponent extends StartsWithAbstractComponent { +export class StartsWithDateComponent extends StartsWithAbstractComponent implements OnInit { /** * A list of options for months to select from @@ -33,14 +30,6 @@ export class StartsWithDateComponent extends StartsWithAbstractComponent { */ startsWithYear: number; - public constructor(@Inject('startsWithOptions') public startsWithOptions: any[], - @Inject('paginationId') public paginationId: string, - protected paginationService: PaginationService, - protected route: ActivatedRoute, - protected router: Router) { - super(startsWithOptions, paginationId, paginationService, route, router); - } - ngOnInit() { this.monthOptions = [ 'none', @@ -133,13 +122,6 @@ export class StartsWithDateComponent extends StartsWithAbstractComponent { } } - /** - * Get startsWithYear as a number; - */ - getStartsWithYear() { - return this.startsWithYear; - } - /** * Get startsWithMonth as a number; */ diff --git a/src/app/shared/starts-with/starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts index ad9c56c9702..22e069e2fbe 100644 --- a/src/app/shared/starts-with/starts-with-abstract.component.ts +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -1,9 +1,10 @@ -import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, Input } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { hasValue } from '../empty.util'; import { PaginationService } from '../../core/pagination/pagination.service'; +import { StartsWithType } from './starts-with-decorator'; /** * An abstract component to render StartsWith options @@ -13,6 +14,13 @@ import { PaginationService } from '../../core/pagination/pagination.service'; template: '' }) export abstract class StartsWithAbstractComponent implements OnInit, OnDestroy { + + @Input() paginationId: string; + + @Input() startsWithOptions: (string | number)[]; + + @Input() type: StartsWithType; + /** * The currently selected startsWith in string format */ @@ -28,11 +36,11 @@ export abstract class StartsWithAbstractComponent implements OnInit, OnDestroy { */ subs: Subscription[] = []; - public constructor(@Inject('startsWithOptions') public startsWithOptions: any[], - @Inject('paginationId') public paginationId: string, - protected paginationService: PaginationService, - protected route: ActivatedRoute, - protected router: Router) { + public constructor( + protected paginationService: PaginationService, + protected route: ActivatedRoute, + protected router: Router, + ) { } ngOnInit(): void { @@ -55,15 +63,6 @@ export abstract class StartsWithAbstractComponent implements OnInit, OnDestroy { return this.startsWith; } - /** - * Set the startsWith by event - * @param event - */ - setStartsWithEvent(event: Event) { - this.startsWith = (event.target as HTMLInputElement).value; - this.setStartsWithParam(); - } - /** * Set the startsWith by string * @param startsWith @@ -82,7 +81,7 @@ export abstract class StartsWithAbstractComponent implements OnInit, OnDestroy { if (resetPage) { this.paginationService.updateRoute(this.paginationId, {page: 1}, { startsWith: this.startsWith }); } else { - this.router.navigate([], { + void this.router.navigate([], { queryParams: Object.assign({ startsWith: this.startsWith }), queryParamsHandling: 'merge' }); diff --git a/src/app/shared/starts-with/starts-with-loader.component.spec.ts b/src/app/shared/starts-with/starts-with-loader.component.spec.ts new file mode 100644 index 00000000000..7eb0ec8819b --- /dev/null +++ b/src/app/shared/starts-with/starts-with-loader.component.spec.ts @@ -0,0 +1,71 @@ +import { StartsWithLoaderComponent } from './starts-with-loader.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { DynamicComponentLoaderDirective } from '../abstract-component-loader/dynamic-component-loader.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { StartsWithTextComponent } from './text/starts-with-text.component'; +import { Router, ActivatedRoute } from '@angular/router'; +import { RouterStub } from '../testing/router.stub'; +import { ThemeService } from '../theme-support/theme.service'; +import { getMockThemeService } from '../mocks/theme-service.mock'; +import { StartsWithType } from './starts-with-decorator'; +import { ActivatedRouteStub } from '../testing/active-router.stub'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../testing/pagination-service.stub'; + +describe('StartsWithLoaderComponent', () => { + let comp: StartsWithLoaderComponent; + let fixture: ComponentFixture; + + let paginationService: PaginationServiceStub; + let route: ActivatedRouteStub; + let themeService: ThemeService; + + const type: StartsWithType = StartsWithType.text; + + beforeEach(waitForAsync(() => { + paginationService = new PaginationServiceStub(); + route = new ActivatedRouteStub(); + themeService = getMockThemeService('dspace'); + + void TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ], + declarations: [ + StartsWithLoaderComponent, + StartsWithTextComponent, + DynamicComponentLoaderDirective, + ], + providers: [ + { provide: PaginationService, useValue: paginationService }, + { provide: ActivatedRoute, useValue: route }, + { provide: Router, useValue: new RouterStub() }, + { provide: ThemeService, useValue: themeService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(StartsWithLoaderComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + entryComponents: [StartsWithTextComponent], + } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(StartsWithLoaderComponent); + comp = fixture.componentInstance; + comp.type = type; + comp.paginationId = 'bbm'; + comp.startsWithOptions = []; + spyOn(comp, 'getComponent').and.returnValue(StartsWithTextComponent); + + fixture.detectChanges(); + })); + + describe('When the component is rendered', () => { + it('should call the getComponent function', () => { + expect(comp.getComponent).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/shared/starts-with/starts-with-loader.component.ts b/src/app/shared/starts-with/starts-with-loader.component.ts new file mode 100644 index 00000000000..085daa4dd0e --- /dev/null +++ b/src/app/shared/starts-with/starts-with-loader.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, } from '@angular/core'; +import { AbstractComponentLoaderComponent } from '../abstract-component-loader/abstract-component-loader.component'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { getStartsWithComponent, StartsWithType } from './starts-with-decorator'; +import { StartsWithAbstractComponent } from './starts-with-abstract.component'; + +/** + * Component for loading a {@link StartsWithAbstractComponent} depending on the "type" input + */ +@Component({ + selector: 'ds-starts-with-loader', + templateUrl: '../abstract-component-loader/abstract-component-loader.component.html', +}) +export class StartsWithLoaderComponent extends AbstractComponentLoaderComponent { + + @Input() paginationId: string; + + @Input() startsWithOptions: (string | number)[]; + + @Input() type: StartsWithType; + + protected inputNames: (keyof this & string)[] = [ + ...this.inputNames, + 'paginationId', + 'startsWithOptions', + 'type', + ]; + + public getComponent(): GenericConstructor { + return getStartsWithComponent(this.type); + } + +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts index b717c72d76b..e282294090e 100644 --- a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts +++ b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts @@ -10,25 +10,31 @@ import { By } from '@angular/platform-browser'; import { StartsWithTextComponent } from './starts-with-text.component'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../testing/pagination-service.stub'; +import { ActivatedRouteStub } from '../../testing/active-router.stub'; +import { RouterStub } from '../../testing/router.stub'; describe('StartsWithTextComponent', () => { let comp: StartsWithTextComponent; let fixture: ComponentFixture; - let route: ActivatedRoute; - let router: Router; + + let paginationService: PaginationServiceStub; + let route: ActivatedRouteStub; + let router: RouterStub; const options = ['0-9', 'A', 'B', 'C']; - const paginationService = new PaginationServiceStub(); + beforeEach(waitForAsync(async () => { + paginationService = new PaginationServiceStub(); + route = new ActivatedRouteStub(); + router = new RouterStub(); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + await TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [StartsWithTextComponent, EnumKeysPipe], providers: [ - { provide: 'startsWithOptions', useValue: options }, - { provide: 'paginationId', useValue: 'page-id' }, - { provide: PaginationService, useValue: paginationService } + { provide: PaginationService, useValue: paginationService }, + { provide: ActivatedRoute, useValue: route }, + { provide: Router, useValue: router }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -37,10 +43,9 @@ describe('StartsWithTextComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(StartsWithTextComponent); comp = fixture.componentInstance; + comp.paginationId = 'page-id'; + comp.startsWithOptions = options; fixture.detectChanges(); - route = (comp as any).route; - router = (comp as any).router; - spyOn(router, 'navigate'); }); it('should create a FormGroup containing a startsWith FormControl', () => { diff --git a/src/app/shared/starts-with/text/starts-with-text.component.ts b/src/app/shared/starts-with/text/starts-with-text.component.ts index 9f44fc4d40b..592f973aa68 100644 --- a/src/app/shared/starts-with/text/starts-with-text.component.ts +++ b/src/app/shared/starts-with/text/starts-with-text.component.ts @@ -35,15 +35,4 @@ export class StartsWithTextComponent extends StartsWithAbstractComponent { super.setStartsWithParam(resetPage); } - /** - * Checks whether the provided option is equal to the current startsWith - * @param option - */ - isSelectedOption(option: string): boolean { - if (this.startsWith === '0' && option === '0-9') { - return true; - } - return option === this.startsWith; - } - } diff --git a/src/app/shared/testing/object-cache-service.stub.ts b/src/app/shared/testing/object-cache-service.stub.ts new file mode 100644 index 00000000000..f62f3575c35 --- /dev/null +++ b/src/app/shared/testing/object-cache-service.stub.ts @@ -0,0 +1,31 @@ +import { Observable, of as observableOf } from 'rxjs'; +import { CacheableObject } from '../../core/cache/cacheable-object.model'; +import { ObjectCacheEntry } from '../../core/cache/object-cache.reducer'; + +/* eslint-disable @typescript-eslint/no-empty-function */ +/** + * Stub class of {@link ObjectCacheService} + */ +export class ObjectCacheServiceStub { + + add(_object: CacheableObject, _msToLive: number, _requestUUID: string, _alternativeLink?: string): void { + } + + remove(_href: string): void { + } + + getByHref(_href: string): Observable { + return observableOf(undefined); + } + + hasByHref$(_href: string): Observable { + return observableOf(false); + } + + addDependency(_href$: string | Observable, _dependsOnHref$: string | Observable): void { + } + + removeDependents(_href: string): void { + } + +} diff --git a/src/app/shared/utils/ipV4.validator.spec.ts b/src/app/shared/utils/ipV4.validator.spec.ts new file mode 100644 index 00000000000..93f5ee86e9a --- /dev/null +++ b/src/app/shared/utils/ipV4.validator.spec.ts @@ -0,0 +1,36 @@ +import { IpV4Validator } from './ipV4.validator'; +import { TestBed } from '@angular/core/testing'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; + + +describe('IpV4 validator', () => { + + let ipV4Validator: IpV4Validator; + const validIp = '192.168.0.1'; + const formGroup = new UntypedFormGroup({ + ip: new UntypedFormControl(''), + }); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + IpV4Validator, + ], + }).compileComponents(); + + ipV4Validator = TestBed.inject(IpV4Validator); + }); + + it('should return null for valid ipV4', () => { + formGroup.controls.ip.setValue(validIp); + expect(ipV4Validator.validate(formGroup.controls.ip as UntypedFormControl)).toBeNull(); + }); + + it('should return {isValidIp: false} for invalid Ip', () => { + formGroup.controls.ip.setValue('100.260.45.1'); + expect(ipV4Validator.validate(formGroup.controls.ip as UntypedFormControl)).toEqual({isValidIp: false}); + formGroup.controls.ip.setValue('100'); + expect(ipV4Validator.validate(formGroup.controls.ip as UntypedFormControl)).toEqual({isValidIp: false}); + formGroup.controls.ip.setValue('testString'); + expect(ipV4Validator.validate(formGroup.controls.ip as UntypedFormControl)).toEqual({isValidIp: false}); + }); +}); diff --git a/src/app/shared/utils/ipV4.validator.ts b/src/app/shared/utils/ipV4.validator.ts new file mode 100644 index 00000000000..170dbeb5470 --- /dev/null +++ b/src/app/shared/utils/ipV4.validator.ts @@ -0,0 +1,26 @@ +import {Directive} from '@angular/core'; +import {NG_VALIDATORS, Validator, UntypedFormControl} from '@angular/forms'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[ipV4format]', + providers: [ + { provide: NG_VALIDATORS, useExisting: IpV4Validator, multi: true }, + ] +}) +/** + * Validator to validate if an Ip is in the right format + */ +export class IpV4Validator implements Validator { + validate(formControl: UntypedFormControl): {[key: string]: boolean} | null { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + const ipValue = formControl.value; + const ipParts = ipValue?.split('.'); + + if (ipv4Regex.test(ipValue) && ipParts.every(part => parseInt(part, 10) <= 255)) { + return null; + } + + return {isValidIp: false}; + } +} diff --git a/src/app/shared/utils/split.pipe.ts b/src/app/shared/utils/split.pipe.ts new file mode 100644 index 00000000000..e4d0f2cc49c --- /dev/null +++ b/src/app/shared/utils/split.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; +/** + * Custom pipe to split a string into an array of substrings based on a specified separator. + * @param value - The string to be split. + * @param separator - The separator used to split the string. + * @returns An array of substrings. + */ +@Pipe({ + name: 'dsSplit' +}) +export class SplitPipe implements PipeTransform { + transform(value: string, separator: string): string[] { + return value.split(separator); + } + +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html index a78d737640c..f967ffe132a 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.html +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -32,7 +32,7 @@ + diff --git a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html index 535395e5345..040987d37bd 100644 --- a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html +++ b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html @@ -1,25 +1,28 @@
- +
- -
- -
diff --git a/src/app/submission/import-external/submission-import-external.component.html b/src/app/submission/import-external/submission-import-external.component.html index dc46e6758fd..8d13b1785a0 100644 --- a/src/app/submission/import-external/submission-import-external.component.html +++ b/src/app/submission/import-external/submission-import-external.component.html @@ -1,7 +1,7 @@
- +

{{'submission.import-external.title' + ((label) ? '.' + label : '') | translate}}

@@ -11,7 +11,7 @@
diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.html b/src/app/submission/sections/duplicates/section-duplicates.component.html new file mode 100644 index 00000000000..78c9e5df282 --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.html @@ -0,0 +1,20 @@ + +
+ +
{{ 'submission.sections.duplicates.none' | translate }}
+
+ +
{{ 'submission.sections.duplicates.detected' | translate }}
+
+ {{dupe.title}} +
+ {{('item.preview.' + metadatum.key) | translate}} {{metadatum.value}} +
+

{{ 'submission.sections.duplicates.in-workspace' | translate }}

+

{{ 'submission.sections.duplicates.in-workflow' | translate }}

+
+ +
diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts new file mode 100644 index 00000000000..2c581fee97e --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts @@ -0,0 +1,248 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { NgxPaginationModule } from 'ngx-pagination'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { SectionsService } from '../sections.service'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; +import { getMockFormService } from '../../../shared/mocks/form-service.mock'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; +import { SectionsType } from '../sections-type'; +import { mockSubmissionCollectionId, mockSubmissionId } from '../../../shared/mocks/submission.mock'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SubmissionSectionDuplicatesComponent } from './section-duplicates.component'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { License } from '../../../core/shared/license.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { Duplicate} from '../../../shared/object-list/duplicate-data/duplicate.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { defaultUUID } from '../../../shared/mocks/uuid.service.mock'; +import { DUPLICATE } from '../../../shared/object-list/duplicate-data/duplicate.resource-type'; + +function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService { + return jasmine.createSpyObj('FormOperationsService', { + getConfigAll: jasmine.createSpy('getConfigAll'), + getConfigByHref: jasmine.createSpy('getConfigByHref'), + getConfigByName: jasmine.createSpy('getConfigByName'), + getConfigBySearch: jasmine.createSpy('getConfigBySearch') + }); +} + +function getMockCollectionDataService(): CollectionDataService { + return jasmine.createSpyObj('CollectionDataService', { + findById: jasmine.createSpy('findById'), + findByHref: jasmine.createSpy('findByHref') + }); +} + +const duplicates: Duplicate[] = [{ + title: 'Unique title', + uuid: defaultUUID, + workflowItemId: 1, + workspaceItemId: 2, + owningCollection: 'Test Collection', + metadata: { + 'dc.title': [ + Object.assign(new MetadataValue(), { + 'value': 'Unique title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + })] + }, + type: DUPLICATE, + _links: { + self: { + href: 'http://localhost:8080/server/api/core/submission/duplicates/search?uuid=testid' + } + } + }]; + +const sectionObject = { + header: 'submission.sections.submit.progressbar.duplicates', + mandatory: true, + opened: true, + data: {potentialDuplicates: duplicates}, + errorsToShow: [], + serverValidationErrors: [], + id: 'duplicates', + sectionType: SectionsType.Duplicates, + sectionVisibility: null +}; + +describe('SubmissionSectionDuplicatesComponent test suite', () => { + let comp: SubmissionSectionDuplicatesComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: any = new SubmissionServiceStub(); + const sectionsServiceStub: any = new SectionsServiceStub(); + let formService: any; + let formOperationsService: any; + let formBuilderService: any; + let collectionDataService: any; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); + + const licenseText = 'License text'; + const mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })) + }); + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + NgxPaginationModule, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + declarations: [ + SubmissionSectionDuplicatesComponent, + TestComponent, + ObjNgFor, + VarDirective, + ], + providers: [ + { provide: CollectionDataService, useValue: getMockCollectionDataService() }, + { provide: SectionFormOperationsService, useValue: getMockFormOperationsService() }, + { provide: FormService, useValue: getMockFormService() }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + { provide: SubmissionFormsConfigDataService, useValue: getMockSubmissionFormsConfigService() }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: 'collectionIdProvider', useValue: collectionId }, + { provide: 'sectionDataProvider', useValue: sectionObject }, + { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: PaginationService, useValue: paginationService }, + ChangeDetectorRef, + FormBuilderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.getSectionData.and.returnValue(observableOf(sectionObject)); + testFixture = TestBed.createComponent(SubmissionSectionDuplicatesComponent); + testComp = testFixture.componentInstance; + + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create SubmissionSectionDuplicatesComponent', () => { + expect(testComp).toBeTruthy(); + }); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionDuplicatesComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.inject(SubmissionService); + formService = TestBed.inject(FormService); + formBuilderService = TestBed.inject(FormBuilderService); + formOperationsService = TestBed.inject(SectionFormOperationsService); + collectionDataService = TestBed.inject(CollectionDataService); + compAsAny.pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionObject.id); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + // Test initialisation of the submission section + it('Should init section properly', () => { + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + compAsAny.submissionService.getSubmissionScope.and.returnValue(SubmissionScopeType.WorkspaceItem); + spyOn(comp, 'getSectionStatus').and.returnValue(observableOf(true)); + spyOn(comp, 'getDuplicateData').and.returnValue(observableOf({potentialDuplicates: duplicates})); + expect(comp.isLoading).toBeTruthy(); + comp.onSectionInit(); + fixture.detectChanges(); + expect(comp.isLoading).toBeFalsy(); + }); + + // The following tests look for proper logic in the getSectionStatus() implementation + // These are very simple as we don't really have a 'false' state unless we're still loading + it('Should return TRUE if the isLoading is FALSE', () => { + compAsAny.isLoading = false; + expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { + a: true + })); + }); + it('Should return FALSE', () => { + compAsAny.isLoadin = true; + expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { + a: false + })); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.ts b/src/app/submission/sections/duplicates/section-duplicates.component.ts new file mode 100644 index 00000000000..c929ab9ce2f --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.ts @@ -0,0 +1,124 @@ +import {ChangeDetectionStrategy, Component, Inject } from '@angular/core'; + +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { SectionsType } from '../sections-type'; +import { SectionModelComponent } from '../models/section.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { SubmissionService } from '../../submission.service'; +import { AlertType } from '../../../shared/alert/alert-type'; +import { SectionsService } from '../sections.service'; +import { + WorkspaceitemSectionDuplicatesObject +} from '../../../core/submission/models/workspaceitem-section-duplicates.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { getItemModuleRoute } from '../../../item-page/item-page-routing-paths'; + +/** + * Detect duplicates step + * + * @author Kim Shepherd + */ +@Component({ + selector: 'ds-submission-section-duplicates', + templateUrl: './section-duplicates.component.html', + changeDetection: ChangeDetectionStrategy.Default +}) + +@renderSectionFor(SectionsType.Duplicates) +export class SubmissionSectionDuplicatesComponent extends SectionModelComponent { + protected readonly Metadata = Metadata; + /** + * The Alert categories. + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * Variable to track if the section is loading. + * @type {boolean} + */ + public isLoading = true; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize instance variables. + * + * @param {TranslateService} translate + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {string} injectedCollectionId + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + constructor(protected translate: TranslateService, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + super.ngOnInit(); + } + + /** + * Initialize all instance variables and retrieve configuration. + */ + onSectionInit() { + this.isLoading = false; + } + + /** + * Check if identifier section has read-only visibility + */ + isReadOnly(): boolean { + return true; + } + + /** + * Unsubscribe from all subscriptions, if needed. + */ + onSectionDestroy(): void { + return; + } + + /** + * Get section status. Because this simple component never requires human interaction, this is basically + * always going to be the opposite of "is this section still loading". This is not the place for API response + * error checking but determining whether the step can 'proceed'. + * + * @return Observable + * the section status + */ + public getSectionStatus(): Observable { + return observableOf(!this.isLoading); + } + + /** + * Get duplicate data as observable from the section data + */ + public getDuplicateData(): Observable { + return this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) as + Observable; + } + + /** + * Construct and return an item link for use with a preview item stub + * @param uuid + */ + public getItemLink(uuid: any) { + return new URLCombiner(getItemModuleRoute(), uuid).toString(); + } + + +} diff --git a/src/app/submission/sections/license/section-license.component.ts b/src/app/submission/sections/license/section-license.component.ts index e9a0cf15668..4b2e6761cc6 100644 --- a/src/app/submission/sections/license/section-license.component.ts +++ b/src/app/submission/sections/license/section-license.component.ts @@ -213,6 +213,7 @@ export class SubmissionSectionLicenseComponent extends SectionModelComponent { } else { this.operationsBuilder.remove(this.pathCombiner.getPath(path)); } + this.submissionService.dispatchSaveSection(this.submissionId, this.sectionData.id); } /** diff --git a/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts new file mode 100644 index 00000000000..ecb8a2a61da --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts @@ -0,0 +1,92 @@ +import { CoarNotifyConfigDataService } from './coar-notify-config-data.service'; +import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec'; +import { FindAllData } from '../../../core/data/base/find-all-data'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.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 { RestResponse } from '../../../core/cache/response.models'; +import { of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { testPatchDataImplementation } from '../../../core/data/base/patch-data.spec'; +import { testDeleteDataImplementation } from '../../../core/data/base/delete-data.spec'; +import { DeleteData } from '../../../core/data/base/delete-data'; +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'; + +describe('CoarNotifyConfigDataService test', () => { + let scheduler: TestScheduler; + let service: CoarNotifyConfigDataService; + 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/coar-notify`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new CoarNotifyConfigDataService( + 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 initCreateService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as CreateData; + const initFindAllService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as FindAllData; + const initDeleteService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as DeleteData; + const initPatchService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as PatchData; + testCreateDataImplementation(initCreateService); + testFindAllDataImplementation(initFindAllService); + testPatchDataImplementation(initPatchService); + testDeleteDataImplementation(initDeleteService); + }); + +}); diff --git a/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts new file mode 100644 index 00000000000..0bf5fe53590 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../../../core/data/base/data-service.decorator'; +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 { SUBMISSION_COAR_NOTIFY_CONFIG } from './section-coar-notify-service.resource-type'; +import { SubmissionCoarNotifyConfig } from './submission-coar-notify.config'; +import { CreateData, CreateDataImpl } from '../../../core/data/base/create-data'; +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 { RequestParam } from '../../../core/cache/models/request-param.model'; + + +/** + * A service responsible for fetching/sending data from/to the REST API on the CoarNotifyConfig endpoint + */ +@Injectable() +@dataService(SUBMISSION_COAR_NOTIFY_CONFIG) +export class CoarNotifyConfigDataService extends IdentifiableDataService implements FindAllData, DeleteData, PatchData, CreateData { + createData: CreateDataImpl; + private findAllData: FindAllDataImpl; + private deleteData: DeleteDataImpl; + private patchData: PatchDataImpl; + private comparator: ChangeAnalyzer; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('submissioncoarnotifyconfigs', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(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); + } + + + create(object: SubmissionCoarNotifyConfig, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + patch(object: SubmissionCoarNotifyConfig, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + update(object: SubmissionCoarNotifyConfig): Observable> { + return this.patchData.update(object); + } + + commitUpdates(method?: RestRequestMethod): void { + return this.patchData.commitUpdates(method); + } + + createPatchFromCache(object: SubmissionCoarNotifyConfig): Observable { + return this.patchData.createPatchFromCache(object); + } + + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + public invoke(serviceName: string, serviceId: string, files: File[]): Observable> { + const requestId = this.requestService.generateRequestId(); + this.getBrowseEndpoint().pipe( + take(1), + map((endpoint: string) => new URLCombiner(endpoint, serviceName, 'submissioncoarnotifyconfigmodel', serviceId).toString()), + map((endpoint: string) => { + const body = this.getInvocationFormData(files); + return new MultipartPostRequest(requestId, endpoint, body); + }) + ).subscribe((request: RestRequest) => this.requestService.send(request)); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + private getInvocationFormData(files: File[]): FormData { + const form: FormData = new FormData(); + files.forEach((file: File) => { + form.append('file', file); + }); + return form; + } +} diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts b/src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts new file mode 100644 index 00000000000..53e41783ced --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts @@ -0,0 +1,13 @@ +/** + * 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 SUBMISSION_COAR_NOTIFY_CONFIG = new ResourceType('submissioncoarnotifyconfig'); + +export const COAR_NOTIFY_WORKSPACEITEM = new ResourceType('workspaceitem'); + diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.html b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.html new file mode 100644 index 00000000000..a10a7101096 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.html @@ -0,0 +1,149 @@ +
+ +
+
+ +
+
+
+
+
+ + +
+ +
+ +
+ + + {{'submission.section.section-coar-notify.small.notification' | translate : {pattern : ldnPattern.pattern} }} + + + + {{ error.message | translate}} + + +
+
+ +
+
{{ 'submission.section.section-coar-notify.selection.description' | translate }}
+
+ {{ ldnServiceByPattern[ldnPattern.pattern].services[serviceIndex].description }} +
+ + + {{ 'submission.section.section-coar-notify.selection.no-description' | translate }} + + +
+
+
+
+
+
+ + {{ 'submission.section.section-coar-notify.notification.error' | translate }} + +
+
+
+
+ +
+
+
+
+
+
+ +

+ {{ 'submission.section.section-coar-notify.info.no-pattern' | translate }} +

+
+
diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss new file mode 100644 index 00000000000..4a3e7072a3b --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss @@ -0,0 +1,5 @@ +// Getting styles for NgbDropdown +@import '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss'; +@import '../../../shared/form/form.component.scss'; + + diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts new file mode 100644 index 00000000000..74e82722a67 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts @@ -0,0 +1,443 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubmissionSectionCoarNotifyComponent } from './section-coar-notify.component'; +import { LdnServicesService } from '../../../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SectionsService } from '../sections.service'; +import { CoarNotifyConfigDataService } from './coar-notify-config-data.service'; +import { ChangeDetectorRef } from '@angular/core'; +import { SubmissionCoarNotifyConfig } from './submission-coar-notify.config'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { of } from 'rxjs'; +import { + LdnService, + LdnServiceByPattern +} from '../../../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; +import { NotifyServicePattern } from '../../../admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('SubmissionSectionCoarNotifyComponent', () => { + let component: SubmissionSectionCoarNotifyComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + + let ldnServicesService: jasmine.SpyObj; + let coarNotifyConfigDataService: jasmine.SpyObj; + let operationsBuilder: jasmine.SpyObj; + let sectionService: jasmine.SpyObj; + let cdRefStub: any; + + + const patterns: SubmissionCoarNotifyConfig[] = Object.assign( + [new SubmissionCoarNotifyConfig()], + { + patterns: [{pattern: 'review', multipleRequest: false}, {pattern: 'endorsment', multipleRequest: false}], + } + ); + const patternsPL = createPaginatedList(patterns); + const coarNotifyConfig = createSuccessfulRemoteDataObject$(patternsPL); + + beforeEach(async () => { + ldnServicesService = jasmine.createSpyObj('LdnServicesService', [ + 'findByInboundPattern', + ]); + coarNotifyConfigDataService = jasmine.createSpyObj( + 'CoarNotifyConfigDataService', + ['findAll'] + ); + operationsBuilder = jasmine.createSpyObj('JsonPatchOperationsBuilder', [ + 'remove', + 'replace', + 'add', + 'flushOperation', + ]); + sectionService = jasmine.createSpyObj('SectionsService', [ + 'dispatchRemoveSectionErrors', + 'getSectionServerErrors', + 'setSectionError', + ]); + cdRefStub = Object.assign({ + detectChanges: () => fixture.detectChanges(), + }); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [SubmissionSectionCoarNotifyComponent], + providers: [ + { provide: LdnServicesService, useValue: ldnServicesService }, + { provide: CoarNotifyConfigDataService, useValue: coarNotifyConfigDataService}, + { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, + { provide: SectionsService, useValue: sectionService }, + { provide: ChangeDetectorRef, useValue: cdRefStub }, + { provide: 'collectionIdProvider', useValue: 'collectionId' }, + { provide: 'sectionDataProvider', useValue: { id: 'sectionId', data: {} }}, + { provide: 'submissionIdProvider', useValue: 'submissionId' }, + NgbDropdown, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubmissionSectionCoarNotifyComponent); + component = fixture.componentInstance; + componentAsAny = component; + + component.patterns = patterns[0].patterns; + coarNotifyConfigDataService.findAll.and.returnValue(coarNotifyConfig); + sectionService.getSectionServerErrors.and.returnValue( + of( + Object.assign([], { + path: 'sections/sectionId/data/notifyCoar', + message: 'error', + }) + ) + ); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('onSectionInit', () => { + it('should call setCoarNotifyConfig and getSectionServerErrorsAndSetErrorsToDisplay', () => { + spyOn(component, 'setCoarNotifyConfig'); + spyOn(componentAsAny, 'getSectionServerErrorsAndSetErrorsToDisplay'); + + component.onSectionInit(); + + expect(component.setCoarNotifyConfig).toHaveBeenCalled(); + expect(componentAsAny.getSectionServerErrorsAndSetErrorsToDisplay).toHaveBeenCalled(); + }); + }); + + describe('onChange', () => { + const ldnPattern = {pattern: 'review', multipleRequest: false}; + const index = 0; + const selectedService: LdnService = Object.assign(new LdnService(), { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [ + { + pattern: 'review', + }, + ], + description: '', + }); + + beforeEach(() => { + component.ldnServiceByPattern[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [] + } as LdnServiceByPattern; + + component.patterns = []; + }); + + it('should do nothing if the selected value is the same as the previous one', () => { + + component.ldnServiceByPattern[ldnPattern.pattern].services[index] = selectedService; + component.onChange(ldnPattern.pattern, index, selectedService); + + expect(componentAsAny.operationsBuilder.remove).not.toHaveBeenCalled(); + expect(componentAsAny.operationsBuilder.replace).not.toHaveBeenCalled(); + expect(componentAsAny.operationsBuilder.add).not.toHaveBeenCalled(); + }); + + it('should remove the path when the selected value is null', () => { + component.ldnServiceByPattern[ldnPattern.pattern].services[index] = selectedService; + component.onChange(ldnPattern.pattern, index, null); + + expect(componentAsAny.operationsBuilder.flushOperation).toHaveBeenCalledWith( + componentAsAny.pathCombiner.getPath([ldnPattern.pattern, '-']) + ); + expect(component.ldnServiceByPattern[ldnPattern.pattern].services[index]).toBeNull(); + expect(component.previousServices[ldnPattern.pattern].services[index]).toBeNull(); + }); + + it('should replace the path when there is a previous value stored and it is different from the new one', () => { + const previousService: LdnService = Object.assign(new LdnService(), { + id: 2, + name: 'service2', + notifyServiceInboundPatterns: [ + { + pattern: 'endorsement', + }, + ], + description: 'test', + }); + component.ldnServiceByPattern[ldnPattern.pattern].services[index] = previousService; + component.previousServices[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [previousService] + } as LdnServiceByPattern; + + component.onChange(ldnPattern.pattern, index, selectedService); + + expect(componentAsAny.operationsBuilder.add).toHaveBeenCalledWith( + componentAsAny.pathCombiner.getPath([ldnPattern.pattern, '-']), + [selectedService.id], + false, + true + ); + expect(component.ldnServiceByPattern[ldnPattern.pattern].services[index]).toEqual( + selectedService + ); + expect(component.previousServices[ldnPattern.pattern].services[index].id).toEqual( + selectedService.id + ); + }); + + it('should add the path when there is no previous value stored', () => { + component.onChange(ldnPattern.pattern, index, selectedService); + + expect(componentAsAny.operationsBuilder.add).toHaveBeenCalledWith( + componentAsAny.pathCombiner.getPath([ldnPattern.pattern, '-']), + [selectedService.id], + false, + true + ); + expect(component.ldnServiceByPattern[ldnPattern.pattern].services[index]).toEqual( + selectedService + ); + expect(component.previousServices[ldnPattern.pattern].services[index].id).toEqual( + selectedService.id + ); + }); + }); + + describe('initSelectedServicesByPattern', () => { + const pattern1 = {pattern: 'review', multipleRequest: false}; + const pattern2 = {pattern: 'endorsement', multipleRequest: false}; + const service1: LdnService = Object.assign(new LdnService(), { + id: 1, + uuid: 1, + name: 'service1', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: pattern1, + }), + ], + }); + const service2: LdnService = Object.assign(new LdnService(), { + id: 2, + uuid: 2, + name: 'service2', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: pattern2, + }), + ], + }); + const service3: LdnService = Object.assign(new LdnService(), { + id: 3, + uuid: 3, + name: 'service3', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: pattern1, + }), + Object.assign(new NotifyServicePattern(), { + pattern: pattern2, + }), + ], + }); + + const services = [service1, service2, service3]; + + beforeEach(() => { + ldnServicesService.findByInboundPattern.and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList(services)) + ); + component.ldnServiceByPattern[pattern1.pattern] = { + allowsMultipleRequests: false, + services: [] + } as LdnServiceByPattern; + + component.ldnServiceByPattern[pattern2.pattern] = { + allowsMultipleRequests: false, + services: [] + } as LdnServiceByPattern; + + component.patterns = [pattern1, pattern2]; + + spyOn(component, 'filterServices').and.callFake((pattern) => { + return of(services); + }); + }); + + it('should initialize the selected services by pattern', () => { + component.initSelectedServicesByPattern(); + + expect(component.ldnServiceByPattern[pattern1.pattern].services).toEqual([null]); + expect(component.ldnServiceByPattern[pattern2.pattern].services).toEqual([null]); + }); + + it('should add the service to the selected services by pattern if the section data has a value for the pattern', () => { + component.sectionData.data[pattern1.pattern] = [service1.uuid, service3.uuid]; + component.sectionData.data[pattern2.pattern] = [service2.uuid, service3.uuid]; + component.initSelectedServicesByPattern(); + + expect(component.ldnServiceByPattern[pattern1.pattern].services).toEqual([ + service1, + service3, + ]); + expect(component.ldnServiceByPattern[pattern2.pattern].services).toEqual([ + service2, + service3, + ]); + }); + }); + + describe('addService', () => { + const ldnPattern = {pattern: 'review', multipleRequest: false}; + const service: any = { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [{ pattern: ldnPattern.pattern }], + }; + + beforeEach(() => { + component.ldnServiceByPattern[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [] + } as LdnServiceByPattern; + }); + + it('should push the new service to the array corresponding to the pattern', () => { + component.addService(ldnPattern, service); + + expect(component.ldnServiceByPattern[ldnPattern.pattern].services).toEqual([service]); + }); + }); + + describe('removeService', () => { + const ldnPattern = {pattern: 'review', multipleRequest: false}; + const service1: LdnService = Object.assign(new LdnService(), { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: ldnPattern.pattern, + }), + ], + }); + const service2: LdnService = Object.assign(new LdnService(), { + id: 2, + name: 'service2', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: ldnPattern.pattern, + }), + ], + }); + const service3: LdnService = Object.assign(new LdnService(), { + id: 3, + name: 'service3', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: ldnPattern.pattern, + }), + ], + }); + + beforeEach(() => { + component.ldnServiceByPattern[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [] + } as LdnServiceByPattern; + }); + + + it('should remove the service at the specified index from the array corresponding to the pattern', () => { + component.ldnServiceByPattern[ldnPattern.pattern].services = [service1, service2, service3]; + component.removeService(ldnPattern, 1); + + expect(component.ldnServiceByPattern[ldnPattern.pattern].services).toEqual([ + service1, + service3, + ]); + }); + }); + + describe('filterServices', () => { + const pattern = 'review'; + const service1: any = { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + const service2: any = { + id: 2, + name: 'service2', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + const service3: any = { + id: 3, + name: 'service3', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + const services = [service1, service2, service3]; + + beforeEach(() => { + ldnServicesService.findByInboundPattern.and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList(services)) + ); + }); + + it('should return an observable of the services that match the given pattern', () => { + component.filterServices(pattern).subscribe((result) => { + expect(result).toEqual(services); + }); + }); + }); + + describe('hasInboundPattern', () => { + const pattern = 'review'; + const service: any = { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + + it('should return true if the service has the specified inbound pattern type', () => { + expect(component.hasInboundPattern(service, pattern)).toBeTrue(); + }); + + it('should return false if the service does not have the specified inbound pattern type', () => { + expect(component.hasInboundPattern(service, 'endorsement')).toBeFalse(); + }); + }); + + describe('getSectionServerErrorsAndSetErrorsToDisplay', () => { + it('should set the validation errors for the current section to display', () => { + const validationErrors = [ + { path: 'sections/sectionId/data/notifyCoar', message: 'error' }, + ]; + sectionService.getSectionServerErrors.and.returnValue( + of(validationErrors) + ); + + componentAsAny.getSectionServerErrorsAndSetErrorsToDisplay(); + + expect(sectionService.setSectionError).toHaveBeenCalledWith( + component.submissionId, + component.sectionData.id, + validationErrors[0] + ); + }); + }); + + describe('onSectionDestroy', () => { + it('should unsubscribe from all subscriptions', () => { + const sub1 = of(null).subscribe(); + const sub2 = of(null).subscribe(); + componentAsAny.subs = [sub1, sub2]; + spyOn(sub1, 'unsubscribe'); + spyOn(sub2, 'unsubscribe'); + component.onSectionDestroy(); + expect(sub1.unsubscribe).toHaveBeenCalled(); + expect(sub2.unsubscribe).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts new file mode 100644 index 00000000000..36517222a0d --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts @@ -0,0 +1,340 @@ +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; +import { SectionModelComponent } from '../models/section.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionsType } from '../sections-type'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SectionsService } from '../sections.service'; +import { SectionDataObject } from '../models/section-data.model'; + +import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; + +import { getFirstCompletedRemoteData, getPaginatedListPayload, getRemoteDataPayload } from '../../../core/shared/operators'; +import { LdnServicesService } from '../../../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { + LdnService, + LdnServiceByPattern +} from '../../../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; +import { CoarNotifyConfigDataService } from './coar-notify-config-data.service'; +import { filter, map, take, tap } from 'rxjs/operators'; +import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; +import { SubmissionSectionError } from '../../objects/submission-section-error.model'; +import { LdnPattern } from './submission-coar-notify.config'; + +/** + * This component represents a section that contains the submission section-coar-notify form. + */ +@Component({ + selector: 'ds-submission-section-coar-notify', + templateUrl: './section-coar-notify.component.html', + styleUrls: ['./section-coar-notify.component.scss'], + providers: [NgbDropdown] +}) +@renderSectionFor(SectionsType.CoarNotify) +export class SubmissionSectionCoarNotifyComponent extends SectionModelComponent { + + hasSectionData = false; + /** + * Contains an array of string patterns. + */ + patterns: LdnPattern[] = []; + /** + * An object that maps string keys to arrays of LdnService objects. + * Used to store LdnService objects by pattern. + */ + ldnServiceByPattern: { [key: string]: LdnServiceByPattern } = {}; + /** + * A map representing all services for each pattern + * { + * 'pattern': { + * 'index': 'service.id' + * } + * } + * + * @type {{ [key: string]: {[key: number]: number} }} + * @memberof SubmissionSectionCoarNotifyComponent + */ + previousServices: { [key: string]: LdnServiceByPattern } = {}; + + /** + * The [[JsonPatchOperationPathCombiner]] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + /** + * A map representing all field on their way to be removed + * @type {Map} + */ + protected fieldsOnTheirWayToBeRemoved: Map = new Map(); + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + private filteredServicesByPattern = {}; + + constructor(protected ldnServicesService: LdnServicesService, + // protected formOperationsService: SectionFormOperationsService, + protected operationsBuilder: JsonPatchOperationsBuilder, + protected sectionService: SectionsService, + protected coarNotifyConfigDataService: CoarNotifyConfigDataService, + protected chd: ChangeDetectorRef, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + /** + * Initialize all instance variables + */ + onSectionInit() { + this.setCoarNotifyConfig(); + this.getSectionServerErrorsAndSetErrorsToDisplay(); + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + } + + /** + * Method called when section is initialized + * Retriev available NotifyConfigs + */ + setCoarNotifyConfig() { + this.subs.push( + this.coarNotifyConfigDataService.findAll().pipe( + getFirstCompletedRemoteData() + ).subscribe((data) => { + if (data.hasSucceeded) { + this.patterns = data.payload.page[0].patterns; + this.initSelectedServicesByPattern(); + } + })); + } + + /** + * Handles the change event of a select element. + * @param pattern - The pattern of the select element. + * @param index - The index of the select element. + * @param selectedService - The selected LDN service. + */ + onChange(pattern: string, index: number, selectedService: LdnService | null) { + // do nothing if the selected value is the same as the previous one + if (this.ldnServiceByPattern[pattern].services[index]?.id === selectedService?.id) { + return; + } + + // initialize the previousServices object for the pattern if it does not exist + if (!this.previousServices[pattern]) { + this.previousServices[pattern] = { + services: [], + allowsMultipleRequests: this.patterns.find(ldnPattern => ldnPattern.pattern === pattern)?.multipleRequest + }; + } + + // store the previous value + this.previousServices[pattern].services[index] = this.ldnServiceByPattern[pattern].services[index]; + // set the new value + this.ldnServiceByPattern[pattern].services[index] = selectedService; + + const hasPrevValueStored = hasValue(this.previousServices[pattern].services[index]) && this.previousServices[pattern].services[index].id !== selectedService?.id; + if (hasPrevValueStored) { + // when there is a previous value stored and it is different from the new one + this.operationsBuilder.flushOperation(this.pathCombiner.getPath([pattern, '-'])); + if (this.filteredServicesByPattern[pattern]?.includes(this.previousServices[pattern].services[index])){ + this.operationsBuilder.remove(this.pathCombiner.getPath([pattern, index.toString()])); + } + } + + if (!hasPrevValueStored || (selectedService?.id && hasPrevValueStored)) { + // add the path when there is no previous value stored + this.operationsBuilder.add(this.pathCombiner.getPath([pattern, '-']), [selectedService.id], false, true); + } + // set the previous value to the new value + this.previousServices[pattern].services[index] = this.ldnServiceByPattern[pattern].services[index]; + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); + this.chd.detectChanges(); + } + + /** + * Initializes the selected services by pattern. + * Loops through each pattern and filters the services based on the pattern. + * If the section data has a value for the pattern, it adds the service to the selected services by pattern. + * If the section data does not have a value for the pattern, it adds a null service to the selected services by pattern, + * so that the select element is initialized with a null value and to display the default select input. + */ + initSelectedServicesByPattern(): void { + this.patterns.forEach((ldnPattern) => { + if (hasValue(this.sectionData.data[ldnPattern.pattern])) { + this.subs.push( + this.filterServices(ldnPattern.pattern) + .subscribe((services: LdnService[]) => { + + if (!this.ldnServiceByPattern[ldnPattern.pattern]) { + this.ldnServiceByPattern[ldnPattern.pattern] = { + services: [], + allowsMultipleRequests: ldnPattern.multipleRequest + }; + } + + this.ldnServiceByPattern[ldnPattern.pattern].services = services.filter((service) => { + const selection = (this.sectionData.data[ldnPattern.pattern] as LdnService[]).find((s: LdnService) => s.id === service.id); + this.addService(ldnPattern, selection); + return this.sectionData.data[ldnPattern.pattern].includes(service.uuid); + }); + }) + ); + } else { + this.ldnServiceByPattern[ldnPattern.pattern] = { + services: [], + allowsMultipleRequests: ldnPattern.multipleRequest + }; + this.addService(ldnPattern, null); + } + }); + } + + /** + * Adds a new service to the selected services for the given pattern. + * @param ldnPattern - The pattern to add the new service to. + * @param newService - The new service to add. + */ + addService(ldnPattern: LdnPattern, newService: LdnService) { + // Your logic to add a new service to the selected services for the pattern + // Example: Push the newService to the array corresponding to the pattern + if (!this.ldnServiceByPattern[ldnPattern.pattern]) { + this.ldnServiceByPattern[ldnPattern.pattern] = { + services: [], + allowsMultipleRequests: ldnPattern.multipleRequest + }; + } + this.ldnServiceByPattern[ldnPattern.pattern].services.push(newService); + } + + /** + * Removes the service at the specified index from the array corresponding to the pattern. + * @param ldnPattern - The LDN pattern from which to remove the service + * @param serviceIndex - the service index to remove + */ + removeService(ldnPattern: LdnPattern, serviceIndex: number) { + if (this.ldnServiceByPattern[ldnPattern.pattern]) { + // Remove the service at the specified index from the array + this.ldnServiceByPattern[ldnPattern.pattern].services.splice(serviceIndex, 1); + this.previousServices[ldnPattern.pattern]?.services.splice(serviceIndex, 1); + this.operationsBuilder.flushOperation(this.pathCombiner.getPath([ldnPattern.pattern, '-'])); + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); + } + if (!this.ldnServiceByPattern[ldnPattern.pattern].services.length) { + this.addNewService(ldnPattern); + } + } + + /** + * Method called when dropdowns for the section are initialized + * Retrieve services with corresponding patterns to the dropdowns. + */ + filterServices(pattern: string): Observable { + return this.ldnServicesService.findByInboundPattern(pattern).pipe( + getFirstCompletedRemoteData(), + tap((rd) => { + if (rd.hasFailed) { + throw new Error(`Failed to retrieve services for pattern ${pattern}`); + } + }), + filter((rd) => rd.hasSucceeded), + getRemoteDataPayload(), + getPaginatedListPayload(), + tap(res => { + if (!this.filteredServicesByPattern[pattern]){ + this.filteredServicesByPattern[pattern] = []; + } + if (this.filteredServicesByPattern[pattern].length === 0) { + this.filteredServicesByPattern[pattern].push(...res); + } + }), + map((res: LdnService[]) => res.filter((service) => { + if (!this.hasSectionData){ + this.hasSectionData = this.hasInboundPattern(service, pattern); + } + return this.hasInboundPattern(service, pattern); + })) + ); + } + + /** + * Checks if the given service has the specified inbound pattern type. + * @param service - The service to check. + * @param patternType - The inbound pattern type to look for. + * @returns True if the service has the specified inbound pattern type, false otherwise. + */ + hasInboundPattern(service: any, patternType: string): boolean { + return service.notifyServiceInboundPatterns.some((pattern: { pattern: string }) => { + return pattern.pattern === patternType; + }); + } + + /** + * Retrieves server errors for the current section and sets them to display. + * @returns An Observable that emits the validation errors for the current section. + */ + private getSectionServerErrorsAndSetErrorsToDisplay() { + this.subs.push( + this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( + take(1), + filter((validationErrors) => isNotEmpty(validationErrors)), + ).subscribe((validationErrors: SubmissionSectionError[]) => { + if (isNotEmpty(validationErrors)) { + validationErrors.forEach((error) => { + this.sectionService.setSectionError(this.submissionId, this.sectionData.id, error); + }); + } + })); + } + + /** + * Returns an observable of the errors for the current section that match the given pattern and index. + * @param pattern - The pattern to match against the error paths. + * @param index - The index to match against the error paths. + * @returns An observable of the errors for the current section that match the given pattern and index. + */ + public getShownSectionErrors$(pattern: string, index: number): Observable { + return this.sectionService.getShownSectionErrors(this.submissionId, this.sectionData.id, this.sectionData.sectionType) + .pipe( + take(1), + filter((validationErrors) => isNotEmpty(validationErrors)), + map((validationErrors: SubmissionSectionError[]) => { + return validationErrors.filter((error) => { + const path = `${pattern}/${index}`; + return error.path.includes(path); + }); + }) + ); + } + + /** + * @returns An observable that emits a boolean indicating whether the section has any server errors or not. + */ + protected getSectionStatus(): Observable { + return this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( + map((validationErrors) => isEmpty(validationErrors) + )); + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Add new row to dropdown for multiple service selection + * @param ldnPattern - the related LDN pattern where the service is added + */ + addNewService(ldnPattern: LdnPattern): void { + //idle new service for new selection + this.ldnServiceByPattern[ldnPattern.pattern].services.push(null); + } +} diff --git a/src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts b/src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts new file mode 100644 index 00000000000..41ef69cd7a0 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts @@ -0,0 +1,35 @@ +import { CacheableObject } from '../../../core/cache/cacheable-object.model'; +import { autoserialize, deserialize, deserializeAs, inheritSerialization } from 'cerialize'; + +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { COAR_NOTIFY_WORKSPACEITEM } from './section-coar-notify-service.resource-type'; + + +/** An CoarNotify and its properties. */ +@typedObject +@inheritSerialization(CacheableObject) +export class SubmissionCoarNotifyWorkspaceitemModel extends CacheableObject { + static type = COAR_NOTIFY_WORKSPACEITEM; + + @excludeFromEquals + @autoserialize + endorsement?: number[]; + + @deserializeAs('id') + review?: number[]; + + @autoserialize + ingest?: number[]; + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } +} diff --git a/src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts b/src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts new file mode 100644 index 00000000000..4a8116cddee --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts @@ -0,0 +1,42 @@ +import { ResourceType } from '../../../core/shared/resource-type'; +import { CacheableObject } from '../../../core/cache/cacheable-object.model'; +import { autoserialize, deserialize, deserializeAs, inheritSerialization } from 'cerialize'; + +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { SUBMISSION_COAR_NOTIFY_CONFIG } from './section-coar-notify-service.resource-type'; + +export interface LdnPattern { + pattern: string, + multipleRequest: boolean +} +/** A SubmissionCoarNotifyConfig and its properties. */ +@typedObject +@inheritSerialization(CacheableObject) +export class SubmissionCoarNotifyConfig extends CacheableObject { + static type = SUBMISSION_COAR_NOTIFY_CONFIG; + + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + id: string; + + @deserializeAs('id') + uuid: string; + + @autoserialize + patterns: LdnPattern[]; + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } +} diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts index 6bca8a72526..50d15427d2b 100644 --- a/src/app/submission/sections/sections-type.ts +++ b/src/app/submission/sections/sections-type.ts @@ -9,4 +9,6 @@ export enum SectionsType { SherpaPolicies = 'sherpaPolicy', Identifiers = 'identifiers', Collection = 'collection', + CoarNotify = 'coarnotify', + Duplicates = 'duplicates' } diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html index 2baa6c1555a..0cb79e51cb0 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html @@ -6,7 +6,6 @@