diff --git a/.eslintrc.json b/.eslintrc.json index af1b97849b..4cc8c6dbc5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,16 @@ "eslint-plugin-deprecation", "unused-imports", "eslint-plugin-lodash", - "eslint-plugin-jsonc" + "eslint-plugin-jsonc", + "eslint-plugin-rxjs", + "eslint-plugin-simple-import-sort", + "eslint-plugin-import-newlines", + "eslint-plugin-jsonc", + "dspace-angular-ts", + "dspace-angular-html" + ], + "ignorePatterns": [ + "lint/test/fixture" ], "overrides": [ { @@ -18,7 +27,8 @@ "parserOptions": { "project": [ "./tsconfig.json", - "./cypress/tsconfig.json" + "./cypress/tsconfig.json", + "./lint/tsconfig.json" ], "createDefaultProgram": true }, @@ -27,17 +37,32 @@ "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:@angular-eslint/recommended", - "plugin:@angular-eslint/template/process-inline-templates" + "plugin:@angular-eslint/template/process-inline-templates", + "plugin:rxjs/recommended" ], "rules": { + "indent": [ + "error", + 2, + { + "SwitchCase": 1, + "ignoredNodes": [ + "ClassBody.body > PropertyDefinition[decorators.length > 0] > .key" + ] + } + ], "max-classes-per-file": [ "error", 1 ], "comma-dangle": [ - "off", + "error", "always-multiline" ], + "object-curly-spacing": [ + "error", + "always" + ], "eol-last": [ "error", "always" @@ -104,15 +129,13 @@ "allowTernary": true } ], - "prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint) + "prefer-const": "error", + "no-case-declarations": "error", + "no-extra-boolean-cast": "error", "prefer-spread": "off", "no-underscore-dangle": "off", - - // todo: disabled rules from eslint:recommended, consider re-enabling & fixing "no-prototype-builtins": "off", "no-useless-escape": "off", - "no-case-declarations": "off", - "no-extra-boolean-cast": "off", "@angular-eslint/directive-selector": [ "error", @@ -139,7 +162,6 @@ } ], "@angular-eslint/no-attribute-decorator": "error", - "@angular-eslint/no-forward-ref": "error", "@angular-eslint/no-output-native": "warn", "@angular-eslint/no-output-on-prefix": "warn", "@angular-eslint/no-conflicting-lifecycle": "warn", @@ -183,7 +205,7 @@ ], "@typescript-eslint/type-annotation-spacing": "error", "@typescript-eslint/unified-signatures": "error", - "@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable + "@typescript-eslint/ban-types": "error", "@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/restrict-plus-operands": "warn", @@ -200,17 +222,65 @@ "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-base-to-string": [ + "error", + { + "ignoredTypeNames": [ + "ResourceType", + "Error" + ] + } + ], "deprecation/deprecation": "warn", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", "import/order": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", "import/no-deprecated": "warn", "import/no-namespace": "error", + "import-newlines/enforce": [ + "error", + { + "items": 1, + "semi": true, + "forceSingleLine": true + } + ], + "unused-imports/no-unused-imports": "error", "lodash/import-scope": [ "error", "method" - ] + ], + + "rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases + + // Custom DSpace Angular rules + "dspace-angular-ts/themed-component-classes": "error", + "dspace-angular-ts/themed-component-selectors": "error", + "dspace-angular-ts/themed-component-usages": "error" + } + }, + { + "files": [ + "*.spec.ts" + ], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./cypress/tsconfig.json" + ], + "createDefaultProgram": true + }, + "rules": { + "prefer-const": "off", + + // Custom DSpace Angular rules + "dspace-angular-ts/themed-component-usages": "error" } }, { @@ -221,9 +291,8 @@ "plugin:@angular-eslint/template/recommended" ], "rules": { - // todo: re-enable & fix errors - "@angular-eslint/template/no-negated-async": "off", - "@angular-eslint/template/eqeqeq": "off" + // Custom DSpace Angular rules + "dspace-angular-html/themed-component-usages": "error" } }, { diff --git a/.gitattributes b/.gitattributes index 406640bfcc..b5ad93b1bc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,4 +13,7 @@ *.css eol=lf *.scss eol=lf *.html eol=lf -*.svg eol=lf \ No newline at end of file +*.svg eol=lf + +# Generated documentation should have LF line endings to reduce git noise +docs/lint/**/*.md eol=lf \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52f20470a3..4f2a84ce8a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,12 +33,12 @@ jobs: #CHROME_VERSION: "90.0.4430.212-1" # Bump Node heap size (OOM in CI after upgrading to Angular 15) NODE_OPTIONS: '--max-old-space-size=4096' - # Project name to use when running docker-compose prior to e2e tests + # Project name to use when running "docker compose" prior to e2e tests COMPOSE_PROJECT_NAME: 'ci' strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [16.x, 18.x] + node-version: [18.x, 20.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job @@ -74,7 +74,7 @@ jobs: id: yarn-cache-dir-path run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Cache Yarn dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: # Cache entire Yarn cache directory (see previous step) path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -85,8 +85,14 @@ jobs: - name: Install Yarn dependencies run: yarn install --frozen-lockfile + - name: Build lint plugins + run: yarn run build:lint + + - name: Run lint plugin tests + run: yarn run test:lint:nobuild + - name: Run lint - run: yarn run lint --quiet + run: yarn run lint:nobuild --quiet - name: Check for circular dependencies run: yarn run check-circ-deps @@ -101,19 +107,19 @@ jobs: # so that it can be shared with the 'codecov' job (see below) # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - name: Upload code coverage report to Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: matrix.node-version == '18.x' with: - name: dspace-angular coverage report + name: coverage-report-${{ matrix.node-version }} path: 'coverage/dspace-angular/lcov.info' retention-days: 14 - # Using docker-compose start backend using CI configuration + # Using "docker compose" start backend using CI configuration # and load assetstore from a cached copy - name: Start DSpace REST Backend via Docker (for e2e tests) run: | - docker-compose -f ./docker/docker-compose-ci.yml up -d - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + docker compose -f ./docker/docker-compose-ci.yml up -d + docker compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli docker container ls # Run integration tests via Cypress.io @@ -135,19 +141,19 @@ jobs: # Cypress always creates a video of all e2e tests (whether they succeeded or failed) # Save those in an Artifact - name: Upload e2e test videos to Artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: - name: e2e-test-videos + name: e2e-test-videos-${{ matrix.node-version }} path: cypress/videos # If e2e tests fail, Cypress creates a screenshot of what happened # Save those in an Artifact - name: Upload e2e test failure screenshots to Artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: - name: e2e-test-screenshots + name: e2e-test-screenshots-${{ matrix.node-version }} path: cypress/screenshots - name: Stop app (in case it stays up after e2e tests) @@ -182,7 +188,7 @@ jobs: run: kill -9 $(lsof -t -i:4000) - name: Shutdown Docker containers - run: docker-compose -f ./docker/docker-compose-ci.yml down + run: docker compose -f ./docker/docker-compose-ci.yml down # Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test # job above. This is necessary because Codecov uploads seem to randomly fail at times. @@ -197,7 +203,7 @@ jobs: # Download artifacts from previous 'tests' job - name: Download coverage artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 # Now attempt upload to Codecov using its action. # NOTE: We use a retry action to retry the Codecov upload if it fails the first time. @@ -207,11 +213,12 @@ jobs: - name: Upload coverage to Codecov.io uses: Wandalen/wretry.action@v1.3.0 with: - action: codecov/codecov-action@v3 + action: codecov/codecov-action@v4 # Ensure codecov-action throws an error when it fails to upload # This allows us to auto-restart the action if an error is thrown with: | fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} # Try re-running action 5 times max attempt_limit: 5 # Run again in 30 seconds diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 85a7216113..d0b4cd0939 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,7 +28,7 @@ jobs: # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main with: - build_id: dspace-angular + build_id: dspace-angular-dev image_name: dspace/dspace-angular dockerfile_path: ./Dockerfile secrets: diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml index b4436dca3a..0a35a6a950 100644 --- a/.github/workflows/issue_opened.yml +++ b/.github/workflows/issue_opened.yml @@ -16,7 +16,7 @@ jobs: # Only add to project board if issue is flagged as "needs triage" or has no labels # NOTE: By default we flag new issues as "needs triage" in our issue template if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') - uses: actions/add-to-project@v0.5.0 + uses: actions/add-to-project@v1.0.0 # Note, the authentication token below is an ORG level Secret. # It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token diff --git a/.github/workflows/pull_request_opened.yml b/.github/workflows/pull_request_opened.yml index f16e81c9fd..bbac52af24 100644 --- a/.github/workflows/pull_request_opened.yml +++ b/.github/workflows/pull_request_opened.yml @@ -21,4 +21,4 @@ jobs: # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards # See https://github.com/toshimaru/auto-author-assign - name: Assign PR to creator - uses: toshimaru/auto-author-assign@v2.0.1 + uses: toshimaru/auto-author-assign@v2.1.0 diff --git a/.gitignore b/.gitignore index 7d065aca06..ce44f6b3fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.angular/cache +/.nx /__build__ /__server_build__ /node_modules diff --git a/angular.json b/angular.json index 5e597d4d30..5f0204249b 100644 --- a/angular.json +++ b/angular.json @@ -109,22 +109,22 @@ "serve": { "builder": "@angular-builders/custom-webpack:dev-server", "options": { - "browserTarget": "dspace-angular:build", + "buildTarget": "dspace-angular:build", "port": 4000 }, "configurations": { "development": { - "browserTarget": "dspace-angular:build:development" + "buildTarget": "dspace-angular:build:development" }, "production": { - "browserTarget": "dspace-angular:build:production" + "buildTarget": "dspace-angular:build:production" } } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { - "browserTarget": "dspace-angular:build" + "buildTarget": "dspace-angular:build" } }, "test": { @@ -217,23 +217,23 @@ } }, "serve-ssr": { - "builder": "@nguniversal/builders:ssr-dev-server", + "builder": "@angular-devkit/build-angular:ssr-dev-server", "options": { - "browserTarget": "dspace-angular:build", + "buildTarget": "dspace-angular:build", "serverTarget": "dspace-angular:server", "port": 4000 }, "configurations": { "production": { - "browserTarget": "dspace-angular:build:production", + "buildTarget": "dspace-angular:build:production", "serverTarget": "dspace-angular:server:production" } } }, "prerender": { - "builder": "@nguniversal/builders:prerender", + "builder": "@angular-devkit/build-angular:prerender", "options": { - "browserTarget": "dspace-angular:build:production", + "buildTarget": "dspace-angular:build:production", "serverTarget": "dspace-angular:server:production", "routes": [ "/" @@ -266,6 +266,8 @@ "options": { "lintFilePatterns": [ "src/**/*.ts", + "cypress/**/*.ts", + "lint/**/*.ts", "src/**/*.html", "src/**/*.json5" ] diff --git a/config/config.example.yml b/config/config.example.yml index 4b9c1a27ac..3e9848dad4 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -17,6 +17,13 @@ ui: # Trust X-FORWARDED-* headers from proxies (default = true) useProxies: true +universal: + # Whether to inline "critical" styles into the server-side rendered HTML. + # Determining which styles are critical is a relatively expensive operation; + # this option can be disabled to boost server performance at the expense of + # loading smoothness. + inlineCriticalCss: true + # The REST API server settings # NOTE: these settings define which (publicly available) REST API to use. They are usually # 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. @@ -131,12 +138,16 @@ submission: # NOTE: after how many time (milliseconds) submission is saved automatically # eg. timer: 5 * (1000 * 60); // 5 minutes timer: 0 + # Always show the duplicate detection section if enabled, even if there are no potential duplicates detected + # (a message will be displayed to indicate no matches were found) + duplicateDetection: + alwaysShowSection: false icons: metadata: # NOTE: example of configuration # # NOTE: metadata name # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used # style: fas fa-user - name: dc.author style: fas fa-user @@ -147,18 +158,40 @@ submission: confidence: # NOTE: example of configuration # # NOTE: confidence value - # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used - # style: fa-user + # - value: 600 + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used + # style: text-success + # icon: fa-circle-check + # # NOTE: the class configured in property style is used by default, the icon property could be used in component + # configured to use a 'icon mode' display (mainly in edit-item page) - value: 600 style: text-success + icon: fa-circle-check - value: 500 style: text-info + icon: fa-gear - value: 400 style: text-warning + icon: fa-circle-question + - value: 300 + style: text-muted + icon: fa-thumbs-down + - value: 200 + style: text-muted + icon: fa-circle-exclamation + - value: 100 + style: text-muted + icon: fa-circle-stop + - value: 0 + style: text-muted + icon: fa-ban + - value: -1 + style: text-muted + icon: fa-circle-xmark # default configuration - value: default style: text-muted + icon: fa-circle-xmark # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en @@ -169,6 +202,9 @@ languages: - code: en label: English active: true + - code: ar + label: العربية + active: true - code: ca label: Català active: true @@ -272,6 +308,8 @@ homePage: # No. of communities to list per page on the home page # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 pageSize: 5 + # Enable or disable the Discover filters on the homepage + showDiscoverFilters: false # Item Config item: @@ -285,8 +323,17 @@ item: # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: 5 +# Community Page Config +community: + # Search tab config + searchSection: + showSidebar: true + # Collection Page Config collection: + # Search tab config + searchSection: + showSidebar: true edit: undoTimeout: 10000 # 10 seconds @@ -363,10 +410,11 @@ mediaViewer: # Whether the end user agreement is required before users use the repository. # If enabled, the user will be required to accept the agreement before they can use the repository. -# And whether the privacy statement should exist or not. +# And whether the privacy statement/COAR notify support page should exist or not. info: enableEndUserAgreement: true enablePrivacyStatement: true + enableCOARNotifySupport: true # Whether to enable Markdown (https://commonmark.org/) and MathJax (https://www.mathjax.org/) # display in supported metadata fields. By default, only dc.description.abstract is supported. @@ -392,3 +440,69 @@ comcolSelectionSort: # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af # source: "openaire" + +# Search settings +search: + # Settings to enable/disable or configure advanced search filters. + advancedFilters: + enabled: false + # List of filters to enable in "Advanced Search" dropdown + filter: [ 'title', 'author', 'subject', 'entityType' ] + + +# Notify metrics +# Configuration for Notify Admin Dashboard for metrics visualization +notifyMetrics: + # Configuration for received messages +- title: 'admin-notify-dashboard.received-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.incoming.accepted' + config: 'NOTIFY.incoming.accepted' + description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.incoming.processed' + config: 'NOTIFY.incoming.processed' + description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.failure' + config: 'NOTIFY.incoming.failure' + description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted' + config: 'NOTIFY.incoming.untrusted' + description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems' + textColor: '#fff' + config: 'NOTIFY.incoming.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description' +# Configuration for outgoing messages +- title: 'admin-notify-dashboard.generated-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued' + config: 'NOTIFY.outgoing.queued' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description' + - color: '#FDEEBB' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry' + config: 'NOTIFY.outgoing.queued_for_retry' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.outgoing.failure' + config: 'NOTIFY.outgoing.failure' + description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems' + textColor: '#fff' + config: 'NOTIFY.outgoing.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered' + config: 'NOTIFY.outgoing.delivered' + description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' + + + + + diff --git a/cypress/e2e/admin-sidebar.cy.ts b/cypress/e2e/admin-sidebar.cy.ts index 7612eb5313..be1c9d4ef2 100644 --- a/cypress/e2e/admin-sidebar.cy.ts +++ b/cypress/e2e/admin-sidebar.cy.ts @@ -1,28 +1,28 @@ -import { Options } from 'cypress-axe'; import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; describe('Admin Sidebar', () => { - beforeEach(() => { - // Must login as an Admin for sidebar to appear - cy.visit('/login'); - cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - }); + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); - it('should be pinnable and pass accessibility tests', () => { - // Pin the sidebar open - cy.get('#sidebar-collapse-toggle').click(); + it('should be pinnable and pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').click(); - // Click on every expandable section to open all menus - cy.get('ds-expandable-admin-sidebar-section').click({multiple: true}); + // Click on every expandable section to open all menus + cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true }); - // Analyze for accessibility - testA11y('ds-admin-sidebar', + // Analyze for accessibility + testA11y('ds-admin-sidebar', { - rules: { - // Currently all expandable sections have nested interactive elements - // See https://github.com/DSpace/dspace-angular/issues/2178 - 'nested-interactive': { enabled: false }, - } + rules: { + // Currently all expandable sections have nested interactive elements + // See https://github.com/DSpace/dspace-angular/issues/2178 + 'nested-interactive': { enabled: false }, + }, } as Options); - }); + }); }); diff --git a/cypress/e2e/breadcrumbs.cy.ts b/cypress/e2e/breadcrumbs.cy.ts index 0cddbc723c..f660f47a54 100644 --- a/cypress/e2e/breadcrumbs.cy.ts +++ b/cypress/e2e/breadcrumbs.cy.ts @@ -1,14 +1,14 @@ import { testA11y } from 'cypress/support/utils'; describe('Breadcrumbs', () => { - it('should pass accessibility tests', () => { - // Visit an Item, as those have more breadcrumbs - cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); + it('should pass accessibility tests', () => { + // Visit an Item, as those have more breadcrumbs + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); - // Wait for breadcrumbs to be visible - cy.get('ds-breadcrumbs').should('be.visible'); + // Wait for breadcrumbs to be visible + cy.get('ds-breadcrumbs').should('be.visible'); - // Analyze for accessibility - testA11y('ds-breadcrumbs'); - }); + // Analyze for accessibility + testA11y('ds-breadcrumbs'); + }); }); diff --git a/cypress/e2e/browse-by-author.cy.ts b/cypress/e2e/browse-by-author.cy.ts index 32c470231d..3e914a2f8c 100644 --- a/cypress/e2e/browse-by-author.cy.ts +++ b/cypress/e2e/browse-by-author.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Author', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/author'); + it('should pass accessibility tests', () => { + cy.visit('/browse/author'); - // Wait for to be visible - cy.get('ds-browse-by-metadata').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-metadata').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-metadata'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-metadata'); + }); }); diff --git a/cypress/e2e/browse-by-dateissued.cy.ts b/cypress/e2e/browse-by-dateissued.cy.ts index 7966f1c82e..5fe0543315 100644 --- a/cypress/e2e/browse-by-dateissued.cy.ts +++ b/cypress/e2e/browse-by-dateissued.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Date Issued', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/dateissued'); + it('should pass accessibility tests', () => { + cy.visit('/browse/dateissued'); - // Wait for to be visible - cy.get('ds-browse-by-date').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-date').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-date'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-date'); + }); }); diff --git a/cypress/e2e/browse-by-subject.cy.ts b/cypress/e2e/browse-by-subject.cy.ts index 57ca88d103..0937a2542b 100644 --- a/cypress/e2e/browse-by-subject.cy.ts +++ b/cypress/e2e/browse-by-subject.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Subject', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/subject'); + it('should pass accessibility tests', () => { + cy.visit('/browse/subject'); - // Wait for to be visible - cy.get('ds-browse-by-metadata').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-metadata').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-metadata'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-metadata'); + }); }); diff --git a/cypress/e2e/browse-by-title.cy.ts b/cypress/e2e/browse-by-title.cy.ts index 09195c30df..71a7356ce3 100644 --- a/cypress/e2e/browse-by-title.cy.ts +++ b/cypress/e2e/browse-by-title.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Title', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/title'); + it('should pass accessibility tests', () => { + cy.visit('/browse/title'); - // Wait for to be visible - cy.get('ds-browse-by-title').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-title').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-title'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-title'); + }); }); diff --git a/cypress/e2e/collection-create.cy.ts b/cypress/e2e/collection-create.cy.ts new file mode 100644 index 0000000000..29f7dd5cac --- /dev/null +++ b/cypress/e2e/collection-create.cy.ts @@ -0,0 +1,13 @@ +beforeEach(() => { + cy.visit('/collections/create?parent='.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +it('should show loading component while saving', () => { + const title = 'Test Collection Title'; + cy.get('#title').type(title); + + cy.get('button[type="submit"]').click(); + + cy.get('ds-loading').should('be.visible'); +}); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts index 3e7ecf6141..e1ba1c5eed 100644 --- a/cypress/e2e/collection-edit.cy.ts +++ b/cypress/e2e/collection-edit.cy.ts @@ -3,126 +3,126 @@ import { testA11y } from 'cypress/support/utils'; const COLLECTION_EDIT_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/edit'); beforeEach(() => { - // All tests start with visiting the Edit Collection Page - cy.visit(COLLECTION_EDIT_PAGE); + // All tests start with visiting the Edit Collection Page + cy.visit(COLLECTION_EDIT_PAGE); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); }); describe('Edit Collection > Edit Metadata tab', () => { - it('should pass accessibility tests', () => { - // tag must be loaded - cy.get('ds-edit-collection').should('be.visible'); + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-collection').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-edit-collection'); - }); + // Analyze for accessibility issues + testA11y('ds-edit-collection'); + }); }); describe('Edit Collection > Assign Roles tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="roles"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); - // tag must be loaded - cy.get('ds-collection-roles').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-roles').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-collection-roles'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-roles'); + }); }); describe('Edit Collection > Content Source tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="source"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="source"]').click(); - // tag must be loaded - cy.get('ds-collection-source').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-source').should('be.visible'); - // Check the external source checkbox (to display all fields on the page) - cy.get('#externalSourceCheck').check(); + // Check the external source checkbox (to display all fields on the page) + cy.get('#externalSourceCheck').check(); - // Wait for the source controls to appear - cy.get('ds-collection-source-controls').should('be.visible'); + // Wait for the source controls to appear + // cy.get('ds-collection-source-controls').should('be.visible'); - // Analyze entire page for accessibility issues - testA11y('ds-collection-source'); - }); + // Analyze entire page for accessibility issues + testA11y('ds-collection-source'); + }); }); describe('Edit Collection > Curate tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="curate"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); - // tag must be loaded - cy.get('ds-collection-curate').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-curate').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-collection-curate'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-curate'); + }); }); describe('Edit Collection > Access Control tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="access-control"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); - // tag must be loaded - cy.get('ds-collection-access-control').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-access-control').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-collection-access-control'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-access-control'); + }); }); describe('Edit Collection > Authorizations tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="authorizations"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); - // tag must be loaded - cy.get('ds-collection-authorizations').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-authorizations').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-collection-authorizations'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-authorizations'); + }); }); describe('Edit Collection > Item Mapper tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="mapper"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); - // tag must be loaded - cy.get('ds-collection-item-mapper').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-item-mapper').should('be.visible'); - // Analyze entire page for accessibility issues - testA11y('ds-collection-item-mapper'); + // Analyze entire page for accessibility issues + testA11y('ds-collection-item-mapper'); - // Click on the "Map new Items" tab - cy.get('li[data-test="mapTab"] a').click(); + // Click on the "Map new Items" tab + cy.get('li[data-test="mapTab"] a').click(); - // Make sure search form is now visible - cy.get('ds-search-form').should('be.visible'); + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); - // Analyze entire page (again) for accessibility issues - testA11y('ds-collection-item-mapper'); - }); + // Analyze entire page (again) for accessibility issues + testA11y('ds-collection-item-mapper'); + }); }); describe('Edit Collection > Delete page', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="delete-button"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); - // tag must be loaded - cy.get('ds-delete-collection').should('be.visible'); + // tag must be loaded + cy.get('ds-delete-collection').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-delete-collection'); - }); + // Analyze for accessibility issues + testA11y('ds-delete-collection'); + }); }); diff --git a/cypress/e2e/collection-page.cy.ts b/cypress/e2e/collection-page.cy.ts index 55c10cc6e2..d12536d332 100644 --- a/cypress/e2e/collection-page.cy.ts +++ b/cypress/e2e/collection-page.cy.ts @@ -2,13 +2,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Collection Page', () => { - it('should pass accessibility tests', () => { - cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); + it('should pass accessibility tests', () => { + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); - // tag must be loaded - cy.get('ds-collection-page').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-collection-page'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-page'); + }); }); diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts index 43bf67ce51..3e5a465e39 100644 --- a/cypress/e2e/collection-statistics.cy.ts +++ b/cypress/e2e/collection-statistics.cy.ts @@ -2,36 +2,36 @@ import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Statistics Page', () => { - const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')); + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')); - it('should load if you click on "Statistics" from a Collection page', () => { - cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); - }); + it('should load if you click on "Statistics" from a Collection page', () => { + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); + }); - it('should contain a "Total visits" section', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - cy.get('table[data-test="TotalVisits"]').should('be.visible'); - }); + it('should contain a "Total visits" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); - it('should contain a "Total visits per month" section', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist'); - }); + it('should contain a "Total visits per month" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + // Check just for existence because this table is empty in CI environment as it's historical data + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist'); + }); - it('should pass accessibility tests', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); + it('should pass accessibility tests', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); - // tag must be loaded - cy.get('ds-collection-statistics-page').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-statistics-page').should('be.visible'); - // Verify / wait until "Total Visits" table's label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); - // Analyze for accessibility issues - testA11y('ds-collection-statistics-page'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-statistics-page'); + }); }); diff --git a/cypress/e2e/community-create.cy.ts b/cypress/e2e/community-create.cy.ts new file mode 100644 index 0000000000..96bc003ba2 --- /dev/null +++ b/cypress/e2e/community-create.cy.ts @@ -0,0 +1,13 @@ +beforeEach(() => { + cy.visit('/communities/create'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +it('should show loading component while saving', () => { + const title = 'Test Community Title'; + cy.get('#title').type(title); + + cy.get('button[type="submit"]').click(); + + cy.get('ds-loading').should('be.visible'); +}); diff --git a/cypress/e2e/community-edit.cy.ts b/cypress/e2e/community-edit.cy.ts index 8fc1a7733e..77e260feec 100644 --- a/cypress/e2e/community-edit.cy.ts +++ b/cypress/e2e/community-edit.cy.ts @@ -3,84 +3,84 @@ import { testA11y } from 'cypress/support/utils'; const COMMUNITY_EDIT_PAGE = '/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('/edit'); beforeEach(() => { - // All tests start with visiting the Edit Community Page - cy.visit(COMMUNITY_EDIT_PAGE); + // All tests start with visiting the Edit Community Page + cy.visit(COMMUNITY_EDIT_PAGE); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); }); describe('Edit Community > Edit Metadata tab', () => { - it('should pass accessibility tests', () => { - // tag must be loaded - cy.get('ds-edit-community').should('be.visible'); + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-community').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-edit-community'); - }); + // Analyze for accessibility issues + testA11y('ds-edit-community'); + }); }); describe('Edit Community > Assign Roles tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="roles"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); - // tag must be loaded - cy.get('ds-community-roles').should('be.visible'); + // tag must be loaded + cy.get('ds-community-roles').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-community-roles'); - }); + // Analyze for accessibility issues + testA11y('ds-community-roles'); + }); }); describe('Edit Community > Curate tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="curate"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); - // tag must be loaded - cy.get('ds-community-curate').should('be.visible'); + // tag must be loaded + cy.get('ds-community-curate').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-community-curate'); - }); + // Analyze for accessibility issues + testA11y('ds-community-curate'); + }); }); describe('Edit Community > Access Control tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="access-control"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); - // tag must be loaded - cy.get('ds-community-access-control').should('be.visible'); + // tag must be loaded + cy.get('ds-community-access-control').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-community-access-control'); - }); + // Analyze for accessibility issues + testA11y('ds-community-access-control'); + }); }); describe('Edit Community > Authorizations tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="authorizations"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); - // tag must be loaded - cy.get('ds-community-authorizations').should('be.visible'); + // tag must be loaded + cy.get('ds-community-authorizations').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-community-authorizations'); - }); + // Analyze for accessibility issues + testA11y('ds-community-authorizations'); + }); }); describe('Edit Community > Delete page', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="delete-button"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); - // tag must be loaded - cy.get('ds-delete-community').should('be.visible'); + // tag must be loaded + cy.get('ds-delete-community').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-delete-community'); - }); + // Analyze for accessibility issues + testA11y('ds-delete-community'); + }); }); diff --git a/cypress/e2e/community-list.cy.ts b/cypress/e2e/community-list.cy.ts index c371f6ceae..9b9c87b112 100644 --- a/cypress/e2e/community-list.cy.ts +++ b/cypress/e2e/community-list.cy.ts @@ -2,16 +2,16 @@ import { testA11y } from 'cypress/support/utils'; describe('Community List Page', () => { - it('should pass accessibility tests', () => { - cy.visit('/community-list'); + it('should pass accessibility tests', () => { + cy.visit('/community-list'); - // tag must be loaded - cy.get('ds-community-list-page').should('be.visible'); + // tag must be loaded + cy.get('ds-community-list-page').should('be.visible'); - // Open every expand button on page, so that we can scan sub-elements as well - cy.get('[data-test="expand-button"]').click({ multiple: true }); + // Open every expand button on page, so that we can scan sub-elements as well + cy.get('[data-test="expand-button"]').click({ multiple: true }); - // Analyze for accessibility issues - testA11y('ds-community-list-page'); - }); + // Analyze for accessibility issues + testA11y('ds-community-list-page'); + }); }); diff --git a/cypress/e2e/community-page.cy.ts b/cypress/e2e/community-page.cy.ts index 386bb592a0..5a4441dbae 100644 --- a/cypress/e2e/community-page.cy.ts +++ b/cypress/e2e/community-page.cy.ts @@ -2,13 +2,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Community Page', () => { - it('should pass accessibility tests', () => { - cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); + it('should pass accessibility tests', () => { + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); - // tag must be loaded - cy.get('ds-community-page').should('be.visible'); + // tag must be loaded + cy.get('ds-community-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-community-page'); - }); + // Analyze for accessibility issues + testA11y('ds-community-page'); + }); }); diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts index ca306eff5c..00e23a90b3 100644 --- a/cypress/e2e/community-statistics.cy.ts +++ b/cypress/e2e/community-statistics.cy.ts @@ -2,36 +2,36 @@ import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Statistics Page', () => { - const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')); + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')); - it('should load if you click on "Statistics" from a Community page', () => { - cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); - }); + it('should load if you click on "Statistics" from a Community page', () => { + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); + }); - it('should contain a "Total visits" section', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - cy.get('table[data-test="TotalVisits"]').should('be.visible'); - }); + it('should contain a "Total visits" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); - it('should contain a "Total visits per month" section', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist'); - }); + it('should contain a "Total visits per month" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + // Check just for existence because this table is empty in CI environment as it's historical data + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist'); + }); - it('should pass accessibility tests', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); + it('should pass accessibility tests', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); - // tag must be loaded - cy.get('ds-community-statistics-page').should('be.visible'); + // tag must be loaded + cy.get('ds-community-statistics-page').should('be.visible'); - // Verify / wait until "Total Visits" table's label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); - // Analyze for accessibility issues - testA11y('ds-community-statistics-page'); - }); + // Analyze for accessibility issues + testA11y('ds-community-statistics-page'); + }); }); diff --git a/cypress/e2e/footer.cy.ts b/cypress/e2e/footer.cy.ts index 656e9d4701..4ee1d6669a 100644 --- a/cypress/e2e/footer.cy.ts +++ b/cypress/e2e/footer.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Footer', () => { - it('should pass accessibility tests', () => { - cy.visit('/'); + it('should pass accessibility tests', () => { + cy.visit('/'); - // Footer must first be visible - cy.get('ds-footer').should('be.visible'); + // Footer must first be visible + cy.get('ds-footer').should('be.visible'); - // Analyze for accessibility - testA11y('ds-footer'); - }); + // Analyze for accessibility + testA11y('ds-footer'); + }); }); diff --git a/cypress/e2e/header.cy.ts b/cypress/e2e/header.cy.ts index 9852216e43..043d67dd2b 100644 --- a/cypress/e2e/header.cy.ts +++ b/cypress/e2e/header.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Header', () => { - it('should pass accessibility tests', () => { - cy.visit('/'); + it('should pass accessibility tests', () => { + cy.visit('/'); - // Header must first be visible - cy.get('ds-header').should('be.visible'); + // Header must first be visible + cy.get('ds-header').should('be.visible'); - // Analyze for accessibility - testA11y('ds-header'); - }); + // Analyze for accessibility + testA11y('ds-header'); + }); }); diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts index ff7dbeb852..f9642c0c83 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -1,31 +1,32 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; -import { testA11y } from 'cypress/support/utils'; import '../support/commands'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; + describe('Site Statistics Page', () => { - it('should load if you click on "Statistics" from homepage', () => { - cy.visit('/'); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', '/statistics'); - }); + it('should load if you click on "Statistics" from homepage', () => { + cy.visit('/'); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', '/statistics'); + }); - it('should pass accessibility tests', () => { - // generate 2 view events on an Item's page - cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); - cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); + it('should pass accessibility tests', () => { + // generate 2 view events on an Item's page + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); - cy.visit('/statistics'); + cy.visit('/statistics'); - // tag must be visable - cy.get('ds-site-statistics-page').should('be.visible'); + // tag must be visable + cy.get('ds-site-statistics-page').should('be.visible'); - // Verify / wait until "Total Visits" table's *last* label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT); - // Wait an extra 500ms, just so all entries in Total Visits have loaded. - cy.wait(500); + // Verify / wait until "Total Visits" table's *last* label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT); + // Wait an extra 500ms, just so all entries in Total Visits have loaded. + cy.wait(500); - // Analyze for accessibility issues - testA11y('ds-site-statistics-page'); - }); + // Analyze for accessibility issues + testA11y('ds-site-statistics-page'); + }); }); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts index b4c01a1a94..b13d5a4695 100644 --- a/cypress/e2e/item-edit.cy.ts +++ b/cypress/e2e/item-edit.cy.ts @@ -1,135 +1,135 @@ -import { Options } from 'cypress-axe'; import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit'); beforeEach(() => { - // All tests start with visiting the Edit Item Page - cy.visit(ITEM_EDIT_PAGE); + // All tests start with visiting the Edit Item Page + cy.visit(ITEM_EDIT_PAGE); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); }); describe('Edit Item > Edit Metadata tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="metadata"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="metadata"]').click(); - // tag must be loaded - cy.get('ds-edit-item-page').should('be.visible'); + // tag must be loaded + cy.get('ds-edit-item-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-edit-item-page'); - }); + // Analyze for accessibility issues + testA11y('ds-edit-item-page'); + }); }); describe('Edit Item > Status tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="status"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="status"]').click(); - // tag must be loaded - cy.get('ds-item-status').should('be.visible'); + // tag must be loaded + cy.get('ds-item-status').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-status'); - }); + // Analyze for accessibility issues + testA11y('ds-item-status'); + }); }); describe('Edit Item > Bitstreams tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="bitstreams"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="bitstreams"]').click(); - // tag must be loaded - cy.get('ds-item-bitstreams').should('be.visible'); + // tag must be loaded + cy.get('ds-item-bitstreams').should('be.visible'); - // Table of item bitstreams must also be loaded - cy.get('div.item-bitstreams').should('be.visible'); + // Table of item bitstreams must also be loaded + cy.get('div.item-bitstreams').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-bitstreams', + // Analyze for accessibility issues + testA11y('ds-item-bitstreams', { - rules: { - // Currently Bitstreams page loads a pagination component per Bundle - // and they all use the same 'id="p-dad"'. - 'duplicate-id': { enabled: false }, - } - } as Options - ); - }); + rules: { + // Currently Bitstreams page loads a pagination component per Bundle + // and they all use the same 'id="p-dad"'. + 'duplicate-id': { enabled: false }, + }, + } as Options, + ); + }); }); describe('Edit Item > Curate tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="curate"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); - // tag must be loaded - cy.get('ds-item-curate').should('be.visible'); + // tag must be loaded + cy.get('ds-item-curate').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-curate'); - }); + // Analyze for accessibility issues + testA11y('ds-item-curate'); + }); }); describe('Edit Item > Relationships tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="relationships"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="relationships"]').click(); - // tag must be loaded - cy.get('ds-item-relationships').should('be.visible'); + // tag must be loaded + cy.get('ds-item-relationships').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-relationships'); - }); + // Analyze for accessibility issues + testA11y('ds-item-relationships'); + }); }); describe('Edit Item > Version History tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="versionhistory"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="versionhistory"]').click(); - // tag must be loaded - cy.get('ds-item-version-history').should('be.visible'); + // tag must be loaded + cy.get('ds-item-version-history').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-version-history'); - }); + // Analyze for accessibility issues + testA11y('ds-item-version-history'); + }); }); describe('Edit Item > Access Control tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="access-control"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); - // tag must be loaded - cy.get('ds-item-access-control').should('be.visible'); + // tag must be loaded + cy.get('ds-item-access-control').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-access-control'); - }); + // Analyze for accessibility issues + testA11y('ds-item-access-control'); + }); }); describe('Edit Item > Collection Mapper tab', () => { - it('should pass accessibility tests', () => { - cy.get('a[data-test="mapper"]').click(); + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); - // tag must be loaded - cy.get('ds-item-collection-mapper').should('be.visible'); + // tag must be loaded + cy.get('ds-item-collection-mapper').should('be.visible'); - // Analyze entire page for accessibility issues - testA11y('ds-item-collection-mapper'); + // Analyze entire page for accessibility issues + testA11y('ds-item-collection-mapper'); - // Click on the "Map new collections" tab - cy.get('li[data-test="mapTab"] a').click(); + // Click on the "Map new collections" tab + cy.get('li[data-test="mapTab"] a').click(); - // Make sure search form is now visible - cy.get('ds-search-form').should('be.visible'); + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); - // Analyze entire page (again) for accessibility issues - testA11y('ds-item-collection-mapper'); - }); + // Analyze entire page (again) for accessibility issues + testA11y('ds-item-collection-mapper'); + }); }); diff --git a/cypress/e2e/item-page.cy.ts b/cypress/e2e/item-page.cy.ts index a6a208e9f4..b79b6ac31d 100644 --- a/cypress/e2e/item-page.cy.ts +++ b/cypress/e2e/item-page.cy.ts @@ -1,32 +1,32 @@ import { testA11y } from 'cypress/support/utils'; describe('Item Page', () => { - const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); - const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); - // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] - it('should redirect to the entity page when navigating to an item page', () => { - cy.visit(ITEMPAGE); - cy.location('pathname').should('eq', ENTITYPAGE); - }); + // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] + it('should redirect to the entity page when navigating to an item page', () => { + cy.visit(ITEMPAGE); + cy.location('pathname').should('eq', ENTITYPAGE); + }); - it('should pass accessibility tests', () => { - cy.visit(ENTITYPAGE); + it('should pass accessibility tests', () => { + cy.visit(ENTITYPAGE); - // tag must be loaded - cy.get('ds-item-page').should('be.visible'); + // tag must be loaded + cy.get('ds-item-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-page'); - }); + // Analyze for accessibility issues + testA11y('ds-item-page'); + }); - it('should pass accessibility tests on full item page', () => { - cy.visit(ENTITYPAGE + '/full'); + it('should pass accessibility tests on full item page', () => { + cy.visit(ENTITYPAGE + '/full'); - // tag must be loaded - cy.get('ds-full-item-page').should('be.visible'); + // tag must be loaded + cy.get('ds-full-item-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-full-item-page'); - }); + // Analyze for accessibility issues + testA11y('ds-full-item-page'); + }); }); diff --git a/cypress/e2e/item-statistics.cy.ts b/cypress/e2e/item-statistics.cy.ts index b856744cba..6518f595a9 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -2,42 +2,42 @@ import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Statistics Page', () => { - const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); - it('should load if you click on "Statistics" from an Item/Entity page', () => { - cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); - }); + it('should load if you click on "Statistics" from an Item/Entity page', () => { + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); + }); - it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { - cy.visit(ITEMSTATISTICSPAGE); - cy.get('ds-item-statistics-page').should('be.visible'); - cy.get('ds-item-page').should('not.exist'); - }); + it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('ds-item-statistics-page').should('be.visible'); + cy.get('ds-item-page').should('not.exist'); + }); - it('should contain a "Total visits" section', () => { - cy.visit(ITEMSTATISTICSPAGE); - cy.get('table[data-test="TotalVisits"]').should('be.visible'); - }); + it('should contain a "Total visits" section', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); - it('should contain a "Total visits per month" section', () => { - cy.visit(ITEMSTATISTICSPAGE); - // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist'); - }); + it('should contain a "Total visits per month" section', () => { + cy.visit(ITEMSTATISTICSPAGE); + // Check just for existence because this table is empty in CI environment as it's historical data + cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist'); + }); - it('should pass accessibility tests', () => { - cy.visit(ITEMSTATISTICSPAGE); + it('should pass accessibility tests', () => { + cy.visit(ITEMSTATISTICSPAGE); - // tag must be loaded - cy.get('ds-item-statistics-page').should('be.visible'); + // tag must be loaded + cy.get('ds-item-statistics-page').should('be.visible'); - // Verify / wait until "Total Visits" table's label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); - // Analyze for accessibility issues - testA11y('ds-item-statistics-page'); - }); + // Analyze for accessibility issues + testA11y('ds-item-statistics-page'); + }); }); diff --git a/cypress/e2e/item-template.cy.ts b/cypress/e2e/item-template.cy.ts new file mode 100644 index 0000000000..5f5b21a16a --- /dev/null +++ b/cypress/e2e/item-template.cy.ts @@ -0,0 +1,15 @@ +const ADD_TEMPLATE_ITEM_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/itemtemplate'); + +describe('Item Template', () => { + beforeEach(() => { + cy.visit(ADD_TEMPLATE_ITEM_PAGE); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should load properly', () => { + cy.contains('.ds-header-row .lbl-cell', 'Field', { timeout: 10000 }).should('exist').should('be.visible'); + cy.contains('.ds-header-row b', 'Value', { timeout: 10000 }).should('exist').should('be.visible'); + cy.contains('.ds-header-row b', 'Lang', { timeout: 10000 }).should('exist').should('be.visible'); + cy.contains('.ds-header-row b', 'Edit', { timeout: 10000 }).should('exist').should('be.visible'); + }); +}); diff --git a/cypress/e2e/login-modal.cy.ts b/cypress/e2e/login-modal.cy.ts index c56b98fd26..3d978dfaca 100644 --- a/cypress/e2e/login-modal.cy.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -1,150 +1,150 @@ import { testA11y } from 'cypress/support/utils'; const page = { - openLoginMenu() { - // Click the "Log In" dropdown menu in header - cy.get('ds-themed-navbar [data-test="login-menu"]').click(); - }, - openUserMenu() { - // Once logged in, click the User menu in header - cy.get('ds-themed-navbar [data-test="user-menu"]').click(); - }, - submitLoginAndPasswordByPressingButton(email, password) { - // Enter email - cy.get('ds-themed-navbar [data-test="email"]').type(email); - // Enter password - cy.get('ds-themed-navbar [data-test="password"]').type(password); - // Click login button - cy.get('ds-themed-navbar [data-test="login-button"]').click(); - }, - submitLoginAndPasswordByPressingEnter(email, password) { - // In opened Login modal, fill out email & password, then click Enter - cy.get('ds-themed-navbar [data-test="email"]').type(email); - cy.get('ds-themed-navbar [data-test="password"]').type(password); - cy.get('ds-themed-navbar [data-test="password"]').type('{enter}'); - }, - submitLogoutByPressingButton() { - // This is the POST command that will actually log us out - cy.intercept('POST', '/server/api/authn/logout').as('logout'); - // Click logout button - cy.get('ds-themed-navbar [data-test="logout-button"]').click(); - // Wait until above POST command responds before continuing - // (This ensures next action waits until logout completes) - cy.wait('@logout'); - } + openLoginMenu() { + // Click the "Log In" dropdown menu in header + cy.get('ds-header [data-test="login-menu"]').click(); + }, + openUserMenu() { + // Once logged in, click the User menu in header + cy.get('ds-header [data-test="user-menu"]').click(); + }, + submitLoginAndPasswordByPressingButton(email, password) { + // Enter email + cy.get('ds-header [data-test="email"]').type(email); + // Enter password + cy.get('ds-header [data-test="password"]').type(password); + // Click login button + cy.get('ds-header [data-test="login-button"]').click(); + }, + submitLoginAndPasswordByPressingEnter(email, password) { + // In opened Login modal, fill out email & password, then click Enter + cy.get('ds-header [data-test="email"]').type(email); + cy.get('ds-header [data-test="password"]').type(password); + cy.get('ds-header [data-test="password"]').type('{enter}'); + }, + submitLogoutByPressingButton() { + // This is the POST command that will actually log us out + cy.intercept('POST', '/server/api/authn/logout').as('logout'); + // Click logout button + cy.get('ds-header [data-test="logout-button"]').click(); + // Wait until above POST command responds before continuing + // (This ensures next action waits until logout completes) + cy.wait('@logout'); + }, }; describe('Login Modal', () => { - it('should login when clicking button & stay on same page', () => { - const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); - cy.visit(ENTITYPAGE); + it('should login when clicking button & stay on same page', () => { + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + cy.visit(ENTITYPAGE); - // Login menu should exist - cy.get('ds-log-in').should('exist'); + // Login menu should exist + cy.get('ds-log-in').should('exist'); - // Login, and the tag should no longer exist - page.openLoginMenu(); - cy.get('.form-login').should('be.visible'); + // Login, and the tag should no longer exist + page.openLoginMenu(); + cy.get('.form-login').should('be.visible'); - page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - cy.get('ds-log-in').should('not.exist'); + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); - // Verify we are still on the same page - cy.url().should('include', ENTITYPAGE); + // Verify we are still on the same page + cy.url().should('include', ENTITYPAGE); - // Open user menu, verify user menu & logout button now available - page.openUserMenu(); - cy.get('ds-user-menu').should('be.visible'); - cy.get('ds-log-out').should('be.visible'); - }); + // Open user menu, verify user menu & logout button now available + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + cy.get('ds-log-out').should('be.visible'); + }); - it('should login when clicking enter key & stay on same page', () => { - cy.visit('/home'); + it('should login when clicking enter key & stay on same page', () => { + cy.visit('/home'); - // Open login menu in header & verify tag is visible - page.openLoginMenu(); - cy.get('.form-login').should('be.visible'); + // Open login menu in header & verify tag is visible + page.openLoginMenu(); + cy.get('.form-login').should('be.visible'); - // Login, and the tag should no longer exist - page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - cy.get('.form-login').should('not.exist'); + // Login, and the tag should no longer exist + page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('.form-login').should('not.exist'); - // Verify we are still on homepage - cy.url().should('include', '/home'); + // Verify we are still on homepage + cy.url().should('include', '/home'); - // Open user menu, verify user menu & logout button now available - page.openUserMenu(); - cy.get('ds-user-menu').should('be.visible'); - cy.get('ds-log-out').should('be.visible'); - }); + // Open user menu, verify user menu & logout button now available + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + cy.get('ds-log-out').should('be.visible'); + }); - it('should support logout', () => { - // First authenticate & access homepage - cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - cy.visit('/'); + it('should support logout', () => { + // First authenticate & access homepage + cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.visit('/'); - // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist - cy.get('ds-log-in').should('not.exist'); - cy.get('ds-log-out').should('exist'); + // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist + cy.get('ds-log-in').should('not.exist'); + cy.get('ds-log-out').should('exist'); - // Click logout button - page.openUserMenu(); - page.submitLogoutByPressingButton(); + // Click logout button + page.openUserMenu(); + page.submitLogoutByPressingButton(); - // Verify ds-log-in tag now exists - cy.get('ds-log-in').should('exist'); - cy.get('ds-log-out').should('not.exist'); - }); + // Verify ds-log-in tag now exists + cy.get('ds-log-in').should('exist'); + cy.get('ds-log-out').should('not.exist'); + }); - it('should allow new user registration', () => { - cy.visit('/'); + it('should allow new user registration', () => { + cy.visit('/'); - page.openLoginMenu(); + page.openLoginMenu(); - // Registration link should be visible - cy.get('ds-themed-navbar [data-test="register"]').should('be.visible'); + // Registration link should be visible + cy.get('ds-header [data-test="register"]').should('be.visible'); - // Click registration link & you should go to registration page - cy.get('ds-themed-navbar [data-test="register"]').click(); - cy.location('pathname').should('eq', '/register'); - cy.get('ds-register-email').should('exist'); + // Click registration link & you should go to registration page + cy.get('ds-header [data-test="register"]').click(); + cy.location('pathname').should('eq', '/register'); + cy.get('ds-register-email').should('exist'); - // Test accessibility of this page - testA11y('ds-register-email'); - }); + // Test accessibility of this page + testA11y('ds-register-email'); + }); - it('should allow forgot password', () => { - cy.visit('/'); + it('should allow forgot password', () => { + cy.visit('/'); - page.openLoginMenu(); + page.openLoginMenu(); - // Forgot password link should be visible - cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible'); + // Forgot password link should be visible + cy.get('ds-header [data-test="forgot"]').should('be.visible'); - // Click link & you should go to Forgot Password page - cy.get('ds-themed-navbar [data-test="forgot"]').click(); - cy.location('pathname').should('eq', '/forgot'); - cy.get('ds-forgot-email').should('exist'); + // Click link & you should go to Forgot Password page + cy.get('ds-header [data-test="forgot"]').click(); + cy.location('pathname').should('eq', '/forgot'); + cy.get('ds-forgot-email').should('exist'); - // Test accessibility of this page - testA11y('ds-forgot-email'); - }); + // Test accessibility of this page + testA11y('ds-forgot-email'); + }); - it('should pass accessibility tests in menus', () => { - cy.visit('/'); + it('should pass accessibility tests in menus', () => { + cy.visit('/'); - // Open login menu & verify accessibility - page.openLoginMenu(); - cy.get('ds-log-in').should('exist'); - testA11y('ds-log-in'); + // Open login menu & verify accessibility + page.openLoginMenu(); + cy.get('ds-log-in').should('exist'); + testA11y('ds-log-in'); - // Now login - page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - cy.get('ds-log-in').should('not.exist'); + // Now login + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); - // Open user menu, verify user menu accesibility - page.openUserMenu(); - cy.get('ds-user-menu').should('be.visible'); - testA11y('ds-user-menu'); - }); + // Open user menu, verify user menu accesibility + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + testA11y('ds-user-menu'); + }); }); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index c48656ffcc..159bb4f5e6 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -1,134 +1,134 @@ import { testA11y } from 'cypress/support/utils'; describe('My DSpace page', () => { - it('should display recent submissions and pass accessibility tests', () => { - cy.visit('/mydspace'); + it('should display recent submissions and pass accessibility tests', () => { + cy.visit('/mydspace'); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - cy.get('ds-my-dspace-page').should('be.visible'); + cy.get('ds-my-dspace-page').should('be.visible'); - // At least one recent submission should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); + // At least one recent submission should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); - // Click each filter toggle to open *every* filter - // (As we want to scan filter section for accessibility issues as well) - cy.get('.filter-toggle').click({ multiple: true }); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('.filter-toggle').click({ multiple: true }); - // Analyze for accessibility issues - testA11y('ds-my-dspace-page'); + // Analyze for accessibility issues + testA11y('ds-my-dspace-page'); + }); + + it('should have a working detailed view that passes accessibility tests', () => { + cy.visit('/mydspace'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + cy.get('ds-my-dspace-page').should('be.visible'); + + // Click button in sidebar to display detailed view + cy.get('ds-search-sidebar [data-test="detail-view"]').click(); + + cy.get('ds-object-detail').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-my-dspace-page'); + }); + + // NOTE: Deleting existing submissions is exercised by submission.spec.ts + it('should let you start a new submission & edit in-progress submissions', () => { + cy.visit('/mydspace'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + // Open the New Submission dropdown + cy.get('button[data-test="submission-dropdown"]').click(); + // Click on the "Item" type in that dropdown + cy.get('#entityControlsDropdownMenu button[title="none"]').click(); + + // This should display the (popup window) + cy.get('ds-create-item-parent-selector').should('be.visible'); + + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); + + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click(); + + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); + + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); + + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); + + // Now that we've created a submission, we'll test that we can go back and Edit it. + // Get our Submission URL, to parse out the ID of this new submission + cy.location().then(fullUrl => { + // This will be the full path (/workspaceitems/[id]/edit) + const path = fullUrl.pathname; + // Split on the slashes + const subpaths = path.split('/'); + // Part 2 will be the [id] of the submission + const id = subpaths[2]; + + // Click the "Save for Later" button to save this submission + cy.get('ds-submission-form-footer [data-test="save-for-later"]').click(); + + // "Save for Later" should send us to MyDSpace + cy.url().should('include', '/mydspace'); + + // Close any open notifications, to make sure they don't get in the way of next steps + cy.get('[data-dismiss="alert"]').click({ multiple: true }); + + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // On MyDSpace, find the submission we just created via its ID + cy.get('[data-test="search-box"]').type(id); + cy.get('[data-test="search-button"]').click(); + + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + + // Click the Edit button for this in-progress submission + cy.get('#edit_' + id).click(); + + // Should send us back to the submission form + cy.url().should('include', '/workspaceitems/' + id + '/edit'); + + // Discard our new submission by clicking Discard in Submission form & confirming + cy.get('ds-submission-form-footer [data-test="discard"]').click(); + cy.get('button#discard_submit').click(); + + // Discarding should send us back to MyDSpace + cy.url().should('include', '/mydspace'); }); + }); - it('should have a working detailed view that passes accessibility tests', () => { - cy.visit('/mydspace'); + it('should let you import from external sources', () => { + cy.visit('/mydspace'); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - cy.get('ds-my-dspace-page').should('be.visible'); + // Open the New Import dropdown + cy.get('button[data-test="import-dropdown"]').click(); + // Click on the "Item" type in that dropdown + cy.get('#importControlsDropdownMenu button[title="none"]').click(); - // Click button in sidebar to display detailed view - cy.get('ds-search-sidebar [data-test="detail-view"]').click(); + // New URL should include /import-external, as we've moved to the import page + cy.url().should('include', '/import-external'); - cy.get('ds-object-detail').should('be.visible'); + // The external import searchbox should be visible + cy.get('ds-submission-import-external-searchbar').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-my-dspace-page'); - }); - - // NOTE: Deleting existing submissions is exercised by submission.spec.ts - it('should let you start a new submission & edit in-progress submissions', () => { - cy.visit('/mydspace'); - - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - - // Open the New Submission dropdown - cy.get('button[data-test="submission-dropdown"]').click(); - // Click on the "Item" type in that dropdown - cy.get('#entityControlsDropdownMenu button[title="none"]').click(); - - // This should display the (popup window) - cy.get('ds-create-item-parent-selector').should('be.visible'); - - // Type in a known Collection name in the search box - cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); - - // Click on the button matching that known Collection name - cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click(); - - // New URL should include /workspaceitems, as we've started a new submission - cy.url().should('include', '/workspaceitems'); - - // The Submission edit form tag should be visible - cy.get('ds-submission-edit').should('be.visible'); - - // A Collection menu button should exist & its value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); - - // Now that we've created a submission, we'll test that we can go back and Edit it. - // Get our Submission URL, to parse out the ID of this new submission - cy.location().then(fullUrl => { - // This will be the full path (/workspaceitems/[id]/edit) - const path = fullUrl.pathname; - // Split on the slashes - const subpaths = path.split('/'); - // Part 2 will be the [id] of the submission - const id = subpaths[2]; - - // Click the "Save for Later" button to save this submission - cy.get('ds-submission-form-footer [data-test="save-for-later"]').click(); - - // "Save for Later" should send us to MyDSpace - cy.url().should('include', '/mydspace'); - - // Close any open notifications, to make sure they don't get in the way of next steps - cy.get('[data-dismiss="alert"]').click({multiple: true}); - - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // On MyDSpace, find the submission we just created via its ID - cy.get('[data-test="search-box"]').type(id); - cy.get('[data-test="search-button"]').click(); - - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - - // Click the Edit button for this in-progress submission - cy.get('#edit_' + id).click(); - - // Should send us back to the submission form - cy.url().should('include', '/workspaceitems/' + id + '/edit'); - - // Discard our new submission by clicking Discard in Submission form & confirming - cy.get('ds-submission-form-footer [data-test="discard"]').click(); - cy.get('button#discard_submit').click(); - - // Discarding should send us back to MyDSpace - cy.url().should('include', '/mydspace'); - }); - }); - - it('should let you import from external sources', () => { - cy.visit('/mydspace'); - - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - - // Open the New Import dropdown - cy.get('button[data-test="import-dropdown"]').click(); - // Click on the "Item" type in that dropdown - cy.get('#importControlsDropdownMenu button[title="none"]').click(); - - // New URL should include /import-external, as we've moved to the import page - cy.url().should('include', '/import-external'); - - // The external import searchbox should be visible - cy.get('ds-submission-import-external-searchbar').should('be.visible'); - - // Test for accessibility issues - testA11y('ds-submission-import-external'); - }); + // Test for accessibility issues + testA11y('ds-submission-import-external'); + }); }); diff --git a/cypress/e2e/pagenotfound.cy.ts b/cypress/e2e/pagenotfound.cy.ts index d02aa8541c..968ae2747b 100644 --- a/cypress/e2e/pagenotfound.cy.ts +++ b/cypress/e2e/pagenotfound.cy.ts @@ -1,18 +1,18 @@ import { testA11y } from 'cypress/support/utils'; describe('PageNotFound', () => { - it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { - // request an invalid page (UUIDs at root path aren't valid) - cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); - cy.get('ds-pagenotfound').should('be.visible'); + it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { + // request an invalid page (UUIDs at root path aren't valid) + cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); + cy.get('ds-pagenotfound').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-pagenotfound'); - }); + // Analyze for accessibility issues + testA11y('ds-pagenotfound'); + }); - it('should not contain element ds-pagenotfound when navigating to existing page', () => { - cy.visit('/home'); - cy.get('ds-pagenotfound').should('not.exist'); - }); + it('should not contain element ds-pagenotfound when navigating to existing page', () => { + cy.visit('/home'); + cy.get('ds-pagenotfound').should('not.exist'); + }); }); diff --git a/cypress/e2e/search-navbar.cy.ts b/cypress/e2e/search-navbar.cy.ts index 9dd93c7a2d..0613e5e712 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,64 +1,64 @@ const page = { - fillOutQueryInNavBar(query) { - // Click the magnifying glass - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); - // Fill out a query in input that appears - cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); - }, - submitQueryByPressingEnter() { - cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); - }, - submitQueryByPressingIcon() { - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); - } + fillOutQueryInNavBar(query) { + // Click the magnifying glass + cy.get('ds-header [data-test="header-search-icon"]').click(); + // Fill out a query in input that appears + cy.get('ds-header [data-test="header-search-box"]').type(query); + }, + submitQueryByPressingEnter() { + cy.get('ds-header [data-test="header-search-box"]').type('{enter}'); + }, + submitQueryByPressingIcon() { + cy.get('ds-header [data-test="header-search-icon"]').click(); + }, }; describe('Search from Navigation Bar', () => { - // NOTE: these tests currently assume this query will return results! - const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); + // NOTE: these tests currently assume this query will return results! + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); - it('should go to search page with correct query if submitted (from home)', () => { - cy.visit('/'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // Run the search - page.fillOutQueryInNavBar(query); - page.submitQueryByPressingEnter(); - // New URL should include query param - cy.url().should('include', 'query='.concat(query)); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); - }); + it('should go to search page with correct query if submitted (from home)', () => { + cy.visit('/'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search + page.fillOutQueryInNavBar(query); + page.submitQueryByPressingEnter(); + // New URL should include query param + cy.url().should('include', 'query='.concat(query)); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + }); - it('should go to search page with correct query if submitted (from search)', () => { - cy.visit('/search'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // Run the search - page.fillOutQueryInNavBar(query); - page.submitQueryByPressingEnter(); - // New URL should include query param - cy.url().should('include', 'query='.concat(query)); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); - }); + it('should go to search page with correct query if submitted (from search)', () => { + cy.visit('/search'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search + page.fillOutQueryInNavBar(query); + page.submitQueryByPressingEnter(); + // New URL should include query param + cy.url().should('include', 'query='.concat(query)); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + }); - it('should allow user to also submit query by clicking icon', () => { - cy.visit('/'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // Run the search - page.fillOutQueryInNavBar(query); - page.submitQueryByPressingIcon(); - // New URL should include query param - cy.url().should('include', 'query='.concat(query)); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); - }); + it('should allow user to also submit query by clicking icon', () => { + cy.visit('/'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search + page.fillOutQueryInNavBar(query); + page.submitQueryByPressingIcon(); + // New URL should include query param + cy.url().should('include', 'query='.concat(query)); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + }); }); diff --git a/cypress/e2e/search-page.cy.ts b/cypress/e2e/search-page.cy.ts index 429f4e6da4..62e73c3877 100644 --- a/cypress/e2e/search-page.cy.ts +++ b/cypress/e2e/search-page.cy.ts @@ -1,57 +1,57 @@ -import { Options } from 'cypress-axe'; import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; describe('Search Page', () => { - // NOTE: these tests currently assume this query will return results! - const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); + // NOTE: these tests currently assume this query will return results! + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); - it('should redirect to the correct url when query was set and submit button was triggered', () => { - const queryString = 'Another interesting query string'; - cy.visit('/search'); - // Type query in searchbox & click search button - cy.get('[data-test="search-box"]').type(queryString); - cy.get('[data-test="search-button"]').click(); - cy.url().should('include', 'query=' + encodeURI(queryString)); - }); + it('should redirect to the correct url when query was set and submit button was triggered', () => { + const queryString = 'Another interesting query string'; + cy.visit('/search'); + // Type query in searchbox & click search button + cy.get('[data-test="search-box"]').type(queryString); + cy.get('[data-test="search-button"]').click(); + cy.url().should('include', 'query=' + encodeURI(queryString)); + }); - it('should load results and pass accessibility tests', () => { - cy.visit('/search?query='.concat(query)); - cy.get('[data-test="search-box"]').should('have.value', query); + it('should load results and pass accessibility tests', () => { + cy.visit('/search?query='.concat(query)); + cy.get('[data-test="search-box"]').should('have.value', query); - // tag must be loaded - cy.get('ds-search-page').should('be.visible'); + // tag must be loaded + cy.get('ds-search-page').should('be.visible'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); - // Click each filter toggle to open *every* filter - // (As we want to scan filter section for accessibility issues as well) - cy.get('[data-test="filter-toggle"]').click({ multiple: true }); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); - // Analyze for accessibility issues - testA11y('ds-search-page'); - }); + // Analyze for accessibility issues + testA11y('ds-search-page'); + }); - it('should have a working grid view that passes accessibility tests', () => { - cy.visit('/search?query='.concat(query)); + it('should have a working grid view that passes accessibility tests', () => { + cy.visit('/search?query='.concat(query)); - // Click button in sidebar to display grid view - cy.get('ds-search-sidebar [data-test="grid-view"]').click(); + // Click button in sidebar to display grid view + cy.get('ds-search-sidebar [data-test="grid-view"]').click(); - // tag must be loaded - cy.get('ds-search-page').should('be.visible'); + // tag must be loaded + cy.get('ds-search-page').should('be.visible'); - // At least one grid object (card) should be displayed - cy.get('[data-test="grid-object"]').should('be.visible'); + // At least one grid object (card) should be displayed + cy.get('[data-test="grid-object"]').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-search-page', + // Analyze for accessibility issues + testA11y('ds-search-page', { - rules: { - // Card titles fail this test currently - 'heading-order': { enabled: false } - } - } as Options - ); - }); + rules: { + // Card titles fail this test currently + 'heading-order': { enabled: false }, + }, + } as Options, + ); + }); }); diff --git a/cypress/e2e/submission.cy.ts b/cypress/e2e/submission.cy.ts index 4402410f23..7123d84134 100644 --- a/cypress/e2e/submission.cy.ts +++ b/cypress/e2e/submission.cy.ts @@ -4,224 +4,224 @@ import { Options } from 'cypress-axe'; describe('New Submission page', () => { - // NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts - it('should create a new submission when using /submit path & pass accessibility', () => { - // Test that calling /submit with collection & entityType will create a new submission - cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); + // NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts + it('should create a new submission when using /submit path & pass accessibility', () => { + // Test that calling /submit with collection & entityType will create a new submission + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - // Should redirect to /workspaceitems, as we've started a new submission - cy.url().should('include', '/workspaceitems'); + // Should redirect to /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); - // The Submission edit form tag should be visible - cy.get('ds-submission-edit').should('be.visible'); + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); - // A Collection menu button should exist & it's value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); + // A Collection menu button should exist & it's value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); - // 4 sections should be visible by default - cy.get('div#section_traditionalpageone').should('be.visible'); - cy.get('div#section_traditionalpagetwo').should('be.visible'); - cy.get('div#section_upload').should('be.visible'); - cy.get('div#section_license').should('be.visible'); + // 4 sections should be visible by default + cy.get('div#section_traditionalpageone').should('be.visible'); + cy.get('div#section_traditionalpagetwo').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); - // Test entire page for accessibility - testA11y('ds-submission-edit', + // Test entire page for accessibility + testA11y('ds-submission-edit', { - rules: { - // Author & Subject fields have invalid "aria-multiline" attrs. - // See https://github.com/DSpace/dspace-angular/issues/1272 - 'aria-allowed-attr': { enabled: false }, - // All panels are accordians & fail "aria-required-children" and "nested-interactive". - // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 - 'aria-required-children': { enabled: false }, - 'nested-interactive': { enabled: false }, - // All select boxes fail to have a name / aria-label. - // This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216 - 'select-name': { enabled: false }, - } + rules: { + // Author & Subject fields have invalid "aria-multiline" attrs. + // See https://github.com/DSpace/dspace-angular/issues/1272 + 'aria-allowed-attr': { enabled: false }, + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + // All select boxes fail to have a name / aria-label. + // This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216 + 'select-name': { enabled: false }, + }, - } as Options - ); + } as Options, + ); - // Discard button should work - // Clicking it will display a confirmation, which we will confirm with another click - cy.get('button#discard').click(); - cy.get('button#discard_submit').click(); + // Discard button should work + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); + + it('should block submission & show errors if required fields are missing', () => { + // Create a new submission + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + // Attempt an immediate deposit without filling out any fields + cy.get('button#deposit').click(); + + // A warning alert should display. + cy.get('ds-notification div.alert-success').should('not.exist'); + cy.get('ds-notification div.alert-warning').should('be.visible'); + + // First section should have an exclamation error in the header + // (as it has required fields) + cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible'); + + // Title field should have class "is-invalid" applied, as it's required + cy.get('input#dc_title').should('have.class', 'is-invalid'); + + // Date Year field should also have "is-valid" class + cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid'); + + // FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button. + // Get our Submission URL, to parse out the ID of this submission + cy.location().then(fullUrl => { + // This will be the full path (/workspaceitems/[id]/edit) + const path = fullUrl.pathname; + // Split on the slashes + const subpaths = path.split('/'); + // Part 2 will be the [id] of the submission + const id = subpaths[2]; + + // Even though form is incomplete, the "Save for Later" button should still work + cy.get('button#saveForLater').click(); + + // "Save for Later" should send us to MyDSpace + cy.url().should('include', '/mydspace'); + + // A success alert should be visible + cy.get('ds-notification div.alert-success').should('be.visible'); + // Now, dismiss any open alert boxes (may be multiple, as tests run quickly) + cy.get('[data-dismiss="alert"]').click({ multiple: true }); + + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // On MyDSpace, find the submission we just saved via its ID + cy.get('[data-test="search-box"]').type(id); + cy.get('[data-test="search-button"]').click(); + + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + + // Delete our created submission & confirm deletion + cy.get('button#delete_' + id).click(); + cy.get('button#delete_confirm').click(); + }); + }); + + it('should allow for deposit if all required fields completed & file uploaded', () => { + // Create a new submission + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + // Fill out all required fields (Title, Date) + cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); + cy.get('input#dc_date_issued_year').type('2022'); + + // Confirm the required license by checking checkbox + // (NOTE: requires "force:true" cause Cypress claims this checkbox is covered by its own ) + cy.get('input#granted').check( { force: true } ); + + // Before using Cypress drag & drop, we have to manually trigger the "dragover" event. + // This ensures our UI displays the dropzone that covers the entire submission page. + // (For some reason Cypress drag & drop doesn't trigger this even itself & upload won't work without this trigger) + cy.get('ds-uploader').trigger('dragover'); + + // This is the POST command that will upload the file + cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload'); + + // Upload our DSpace logo via drag & drop onto submission form + // cy.get('div#section_upload') + cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', { + action: 'drag-drop', }); - it('should block submission & show errors if required fields are missing', () => { - // Create a new submission - cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); + // Wait for upload to complete before proceeding + cy.wait('@upload'); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + // Wait for deposit button to not be disabled & click it. + cy.get('button#deposit').should('not.be.disabled').click(); - // Attempt an immediate deposit without filling out any fields - cy.get('button#deposit').click(); + // No warnings should exist. Instead, just successful deposit alert is displayed + cy.get('ds-notification div.alert-warning').should('not.exist'); + cy.get('ds-notification div.alert-success').should('be.visible'); + }); - // A warning alert should display. - cy.get('ds-notification div.alert-success').should('not.exist'); - cy.get('ds-notification div.alert-warning').should('be.visible'); + it('is possible to submit a new "Person" and that form passes accessibility', () => { + // To submit a different entity type, we'll start from MyDSpace + cy.visit('/mydspace'); - // First section should have an exclamation error in the header - // (as it has required fields) - cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + // NOTE: At this time, we MUST login as admin to submit Person objects + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - // Title field should have class "is-invalid" applied, as it's required - cy.get('input#dc_title').should('have.class', 'is-invalid'); + // Open the New Submission dropdown + cy.get('button[data-test="submission-dropdown"]').click(); + // Click on the "Person" type in that dropdown + cy.get('#entityControlsDropdownMenu button[title="Person"]').click(); - // Date Year field should also have "is-valid" class - cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid'); + // This should display the (popup window) + cy.get('ds-create-item-parent-selector').should('be.visible'); - // FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button. - // Get our Submission URL, to parse out the ID of this submission - cy.location().then(fullUrl => { - // This will be the full path (/workspaceitems/[id]/edit) - const path = fullUrl.pathname; - // Split on the slashes - const subpaths = path.split('/'); - // Part 2 will be the [id] of the submission - const id = subpaths[2]; + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); - // Even though form is incomplete, the "Save for Later" button should still work - cy.get('button#saveForLater').click(); + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click(); - // "Save for Later" should send us to MyDSpace - cy.url().should('include', '/mydspace'); + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); - // A success alert should be visible - cy.get('ds-notification div.alert-success').should('be.visible'); - // Now, dismiss any open alert boxes (may be multiple, as tests run quickly) - cy.get('[data-dismiss="alert"]').click({multiple: true}); + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // On MyDSpace, find the submission we just saved via its ID - cy.get('[data-test="search-box"]').type(id); - cy.get('[data-test="search-button"]').click(); + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); + // 3 sections should be visible by default + cy.get('div#section_personStep').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); - // Delete our created submission & confirm deletion - cy.get('button#delete_' + id).click(); - cy.get('button#delete_confirm').click(); - }); - }); - - it('should allow for deposit if all required fields completed & file uploaded', () => { - // Create a new submission - cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); - - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - - // Fill out all required fields (Title, Date) - cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); - cy.get('input#dc_date_issued_year').type('2022'); - - // Confirm the required license by checking checkbox - // (NOTE: requires "force:true" cause Cypress claims this checkbox is covered by its own ) - cy.get('input#granted').check( {force: true} ); - - // Before using Cypress drag & drop, we have to manually trigger the "dragover" event. - // This ensures our UI displays the dropzone that covers the entire submission page. - // (For some reason Cypress drag & drop doesn't trigger this even itself & upload won't work without this trigger) - cy.get('ds-uploader').trigger('dragover'); - - // This is the POST command that will upload the file - cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload'); - - // Upload our DSpace logo via drag & drop onto submission form - // cy.get('div#section_upload') - cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', { - action: 'drag-drop' - }); - - // Wait for upload to complete before proceeding - cy.wait('@upload'); - - // Wait for deposit button to not be disabled & click it. - cy.get('button#deposit').should('not.be.disabled').click(); - - // No warnings should exist. Instead, just successful deposit alert is displayed - cy.get('ds-notification div.alert-warning').should('not.exist'); - cy.get('ds-notification div.alert-success').should('be.visible'); - }); - - it('is possible to submit a new "Person" and that form passes accessibility', () => { - // To submit a different entity type, we'll start from MyDSpace - cy.visit('/mydspace'); - - // This page is restricted, so we will be shown the login form. Fill it out & submit. - // NOTE: At this time, we MUST login as admin to submit Person objects - cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - - // Open the New Submission dropdown - cy.get('button[data-test="submission-dropdown"]').click(); - // Click on the "Person" type in that dropdown - cy.get('#entityControlsDropdownMenu button[title="Person"]').click(); - - // This should display the (popup window) - cy.get('ds-create-item-parent-selector').should('be.visible'); - - // Type in a known Collection name in the search box - cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); - - // Click on the button matching that known Collection name - cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click(); - - // New URL should include /workspaceitems, as we've started a new submission - cy.url().should('include', '/workspaceitems'); - - // The Submission edit form tag should be visible - cy.get('ds-submission-edit').should('be.visible'); - - // A Collection menu button should exist & its value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); - - // 3 sections should be visible by default - cy.get('div#section_personStep').should('be.visible'); - cy.get('div#section_upload').should('be.visible'); - cy.get('div#section_license').should('be.visible'); - - // Test entire page for accessibility - testA11y('ds-submission-edit', + // Test entire page for accessibility + testA11y('ds-submission-edit', { - rules: { - // All panels are accordians & fail "aria-required-children" and "nested-interactive". - // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 - 'aria-required-children': { enabled: false }, - 'nested-interactive': { enabled: false }, - } + rules: { + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + }, - } as Options - ); + } as Options, + ); - // Click the lookup button next to "Publication" field - cy.get('button[data-test="lookup-button"]').click(); + // Click the lookup button next to "Publication" field + cy.get('button[data-test="lookup-button"]').click(); - // A popup modal window should be visible - cy.get('ds-dynamic-lookup-relation-modal').should('be.visible'); + // A popup modal window should be visible + cy.get('ds-dynamic-lookup-relation-modal').should('be.visible'); - // Popup modal should also pass accessibility tests - //testA11y('ds-dynamic-lookup-relation-modal'); - testA11y({ - include: ['ds-dynamic-lookup-relation-modal'], - exclude: [ - ['ul.nav-tabs'] // Tabs at top of model have several issues which seem to be caused by ng-bootstrap - ], - }); - - // Close popup window - cy.get('ds-dynamic-lookup-relation-modal button.close').click(); - - // Back on the form, click the discard button to remove new submission - // Clicking it will display a confirmation, which we will confirm with another click - cy.get('button#discard').click(); - cy.get('button#discard_submit').click(); + // Popup modal should also pass accessibility tests + //testA11y('ds-dynamic-lookup-relation-modal'); + testA11y({ + include: ['ds-dynamic-lookup-relation-modal'], + exclude: [ + ['ul.nav-tabs'], // Tabs at top of model have several issues which seem to be caused by ng-bootstrap + ], }); + + // Close popup window + cy.get('ds-dynamic-lookup-relation-modal button.close').click(); + + // Back on the form, click the discard button to remove new submission + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); }); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index cc3dccba38..091f11d0f7 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -9,51 +9,51 @@ 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) => { - on('task', { - // Define "log" and "table" tasks, used for logging accessibility errors during CI - // Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file - log(message: string) { - console.log(message); - return null; - }, - table(message: string) { - console.table(message); - return null; - }, - // Cypress doesn't have access to the running application in Node.js. - // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. - // Instead, we'll read our running application's config.json, which contains the configs & - // is regenerated at runtime each time the Angular UI application starts up. - readUIConfig() { - // Check if we have a config.json in the src/assets. If so, use that. - // This is where it's written when running "ng e2e" or "yarn serve" - if (fs.existsSync('./src/assets/config.json')) { - return fs.readFileSync('./src/assets/config.json', 'utf8'); - // Otherwise, check the dist/browser/assets - // This is where it's written when running "serve:ssr", which is what CI uses to start the frontend - } else if (fs.existsSync('./dist/browser/assets/config.json')) { - return fs.readFileSync('./dist/browser/assets/config.json', 'utf8'); - } + on('task', { + // Define "log" and "table" tasks, used for logging accessibility errors during CI + // Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file + log(message: string) { + console.log(message); + return null; + }, + table(message: string) { + console.table(message); + return null; + }, + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + readUIConfig() { + // Check if we have a config.json in the src/assets. If so, use that. + // This is where it's written when running "ng e2e" or "yarn serve" + if (fs.existsSync('./src/assets/config.json')) { + return fs.readFileSync('./src/assets/config.json', 'utf8'); + // Otherwise, check the dist/browser/assets + // This is where it's written when running "serve:ssr", which is what CI uses to start the frontend + } else if (fs.existsSync('./dist/browser/assets/config.json')) { + return fs.readFileSync('./dist/browser/assets/config.json', 'utf8'); + } - 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 ; - } - }); + 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/cypress/support/commands.ts b/cypress/support/commands.ts index 7da454e2d0..b3e3b9630b 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -3,8 +3,14 @@ // See docs at https://docs.cypress.io/api/cypress-api/custom-commands // *********************************************** -import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; -import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; +import { + AuthTokenInfo, + TOKENITEM, +} from 'src/app/core/auth/models/auth-token-info.model'; +import { + DSPACE_XSRF_COOKIE, + XSRF_REQUEST_HEADER, +} from 'src/app/core/xsrf/xsrf.constants'; import { v4 as uuidv4 } from 'uuid'; // Declare Cypress namespace to help with Intellisense & code completion in IDEs @@ -57,33 +63,33 @@ declare global { * @param password password to login as */ function login(email: string, password: string): void { - // Create a fake CSRF cookie/token to use in POST - cy.createCSRFCookie().then((csrfToken: string) => { - // get our REST API's base URL, also needed for POST - cy.task('getRestBaseURL').then((baseRestUrl: string) => { - // Now, send login POST request including that CSRF token - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { [XSRF_REQUEST_HEADER]: csrfToken}, - form: true, // indicates the body should be form urlencoded - body: { user: email, password: password } - }).then((resp) => { - // We expect a successful login - expect(resp.status).to.eq(200); - // We expect to have a valid authorization header returned (with our auth token) - expect(resp.headers).to.have.property('authorization'); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken }, + form: true, // indicates the body should be form urlencoded + body: { user: email, password: password }, + }).then((resp) => { + // We expect a successful login + expect(resp.status).to.eq(200); + // We expect to have a valid authorization header returned (with our auth token) + expect(resp.headers).to.have.property('authorization'); - // Initialize our AuthTokenInfo object from the authorization header. - const authheader = resp.headers.authorization as string; - const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); - // Save our AuthTokenInfo object to our dsAuthInfo UI cookie - // This ensures the UI will recognize we are logged in on next "visit()" - cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); - }); - }); + // Save our AuthTokenInfo object to our dsAuthInfo UI cookie + // This ensures the UI will recognize we are logged in on next "visit()" + cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); + }); }); + }); } // Add as a Cypress command (i.e. assign to 'cy.login') Cypress.Commands.add('login', login); @@ -94,12 +100,12 @@ Cypress.Commands.add('login', login); * @param password password to login as */ function loginViaForm(email: string, password: string): void { - // Enter email - cy.get('ds-log-in [data-test="email"]').type(email); - // Enter password - cy.get('ds-log-in [data-test="password"]').type(password); - // Click login button - cy.get('ds-log-in [data-test="login-button"]').click(); + // Enter email + cy.get('ds-log-in [data-test="email"]').type(email); + // Enter password + cy.get('ds-log-in [data-test="password"]').type(password); + // Click login button + cy.get('ds-log-in [data-test="login-button"]').click(); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); @@ -117,29 +123,29 @@ Cypress.Commands.add('loginViaForm', loginViaForm); * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ function generateViewEvent(uuid: string, dsoType: string): void { - // Create a fake CSRF cookie/token to use in POST - cy.createCSRFCookie().then((csrfToken: string) => { - // get our REST API's base URL, also needed for POST - cy.task('getRestBaseURL').then((baseRestUrl: string) => { - // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/statistics/viewevents', - headers: { - [XSRF_REQUEST_HEADER] : csrfToken, - // use a known public IP address to avoid being seen as a "bot" - 'X-Forwarded-For': '1.1.1.1', - // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot" - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', - }, - //form: true, // indicates the body should be form urlencoded - body: { targetId: uuid, targetType: dsoType }, - }).then((resp) => { - // We expect a 201 (which means statistics event was created) - expect(resp.status).to.eq(201); - }); - }); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/statistics/viewevents', + headers: { + [XSRF_REQUEST_HEADER] : csrfToken, + // use a known public IP address to avoid being seen as a "bot" + 'X-Forwarded-For': '1.1.1.1', + // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot" + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + }, + //form: true, // indicates the body should be form urlencoded + body: { targetId: uuid, targetType: dsoType }, + }).then((resp) => { + // We expect a 201 (which means statistics event was created) + expect(resp.status).to.eq(201); + }); }); + }); } // Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') Cypress.Commands.add('generateViewEvent', generateViewEvent); @@ -153,17 +159,17 @@ Cypress.Commands.add('generateViewEvent', generateViewEvent); * @returns a Cypress Chainable which can be used to get the generated CSRF Token */ function createCSRFCookie(): Cypress.Chainable { - // Generate a new token which is a random UUID - const csrfToken: string = uuidv4(); + // Generate a new token which is a random UUID + const csrfToken: string = uuidv4(); - // Save it to our required cookie - cy.task('getRestBaseDomain').then((baseDomain: string) => { - // Create a fake CSRF Token. Set it in the required server-side cookie - cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - }); + // Save it to our required cookie + cy.task('getRestBaseDomain').then((baseDomain: string) => { + // Create a fake CSRF Token. Set it in the required server-side cookie + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); + }); - // return the generated token wrapped in a chainable - return cy.wrap(csrfToken); + // return the generated token wrapped in a chainable + return cy.wrap(csrfToken); } // Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie') Cypress.Commands.add('createCSRFCookie', createCSRFCookie); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index f6c6865052..73d3c76a99 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -15,12 +15,11 @@ // Import all custom Commands (from commands.ts) for all tests import './commands'; - // Import Cypress Axe tools for all tests // https://github.com/component-driven/cypress-axe import 'cypress-axe'; -import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants'; +import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants'; // Runs once before all tests before(() => { @@ -35,18 +34,18 @@ before(() => { // Find URL of our REST API & save to global variable via task let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; if (!config.rest.baseUrl) { - console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); + console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); } else { - baseRestUrl = config.rest.baseUrl; + baseRestUrl = config.rest.baseUrl; } cy.task('saveRestBaseURL', baseRestUrl); // Find domain of our REST API & save to global variable via task. let baseDomain = FALLBACK_TEST_REST_DOMAIN; if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); + console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); } else { - baseDomain = config.rest.host; + baseDomain = config.rest.host; } cy.task('saveRestBaseDomain', baseDomain); @@ -55,12 +54,12 @@ before(() => { // Runs once before the first test in each "block" beforeEach(() => { - // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie - // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); + // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie + // This just ensures it doesn't get in the way of matching other objects in the page. + cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); - // Remove any CSRF cookies saved from prior tests - cy.clearCookie(DSPACE_XSRF_COOKIE); + // Remove any CSRF cookies saved from prior tests + cy.clearCookie(DSPACE_XSRF_COOKIE); }); // NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 96575969e8..9a9ea1121b 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -5,26 +5,26 @@ import { Options } from 'cypress-axe'; // Uses 'log' and 'table' tasks defined in ../plugins/index.ts // Borrowed from https://github.com/component-driven/cypress-axe#in-your-spec-file function terminalLog(violations: Result[]) { - cy.task( - 'log', - `${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected` - ); - // pluck specific keys to keep the table readable - const violationData = violations.map( - ({ id, impact, description, helpUrl, nodes }) => ({ - id, - impact, - description, - helpUrl, - nodes: nodes.length, - html: nodes.map(node => node.html) - }) - ); + cy.task( + 'log', + `${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`, + ); + // pluck specific keys to keep the table readable + const violationData = violations.map( + ({ id, impact, description, helpUrl, nodes }) => ({ + id, + impact, + description, + helpUrl, + nodes: nodes.length, + html: nodes.map(node => node.html), + }), + ); - // Print violations as an array, since 'node.html' above often breaks table alignment - cy.task('log', violationData); - // Optionally, uncomment to print as a table - // cy.task('table', violationData); + // Print violations as an array, since 'node.html' above often breaks table alignment + cy.task('log', violationData); + // Optionally, uncomment to print as a table + // cy.task('table', violationData); } @@ -32,13 +32,13 @@ function terminalLog(violations: Result[]) { // while also ensuring any violations are logged to the terminal (see terminalLog above) // This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load export const testA11y = (context?: any, options?: Options) => { - cy.injectAxe(); - cy.configureAxe({ - rules: [ - // Disable color contrast checks as they are inaccurate / result in a lot of false positives - // See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast - { id: 'color-contrast', enabled: false }, - ] - }); - cy.checkA11y(context, options, terminalLog); + cy.injectAxe(); + cy.configureAxe({ + rules: [ + // Disable color contrast checks as they are inaccurate / result in a lot of false positives + // See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast + { id: 'color-contrast', enabled: false }, + ], + }); + cy.checkA11y(context, options, terminalLog); }; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 58083003cd..51237b5e95 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -4,10 +4,11 @@ "**/*.ts" ], "compilerOptions": { + "sourceMap": false, "types": [ "cypress", "cypress-axe", "node" ] } -} \ No newline at end of file +} diff --git a/docker/README.md b/docker/README.md index d0cee3f52a..64d8ddc2c5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -20,7 +20,7 @@ the Docker compose scripts in this 'docker' folder. ### Dockerfile -This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' +This Dockerfile is used to build a *development* DSpace Angular UI image, published as 'dspace/dspace-angular' ``` docker build -t dspace/dspace-angular:latest . @@ -46,11 +46,11 @@ A default/demo version of this image is built *automatically*. ## 'docker' directory - docker-compose.yml - - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. + - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace REST instance will also be started in Docker. - docker-compose-rest.yml - - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes + - Runs a published instance of the DSpace REST API - persists data in Docker volumes - docker-compose-ci.yml - - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. + - Runs a published instance of the DSpace REST API for CI testing. The database is re-populated from a SQL dump on each startup. - cli.yml - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. - cli.assetstore.yml @@ -71,7 +71,7 @@ docker-compose -f docker/docker-compose.yml build This command provides a quick way to start both the frontend & backend from this single codebase ``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d +docker-compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d ``` Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section. @@ -86,14 +86,14 @@ _The system will be started in 2 steps. Each step shares the same docker network From 'DSpace/DSpace' clone (build first as needed): ``` -docker-compose -p d7 up -d +docker-compose -p d8 up -d ``` NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md). From 'DSpace/dspace-angular' clone (build first as needed) ``` -docker-compose -p d7 -f docker/docker-compose.yml up -d +docker-compose -p d8 -f docker/docker-compose.yml up -d ``` At this point, you should be able to access the UI from http://localhost:4000, @@ -107,19 +107,19 @@ This allows you to run the Angular UI in *production* mode, pointing it at the d ``` docker-compose -f docker/docker-compose-dist.yml pull docker-compose -f docker/docker-compose-dist.yml build -docker-compose -p d7 -f docker/docker-compose-dist.yml up -d +docker-compose -p d8 -f docker/docker-compose-dist.yml up -d ``` ## Ingest test data from AIPDIR Create an administrator ``` -docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en +docker-compose -p d8 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en ``` Load content from AIP files ``` -docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli +docker-compose -p d8 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli ``` ## Alternative Ingest - Use Entities dataset @@ -127,12 +127,12 @@ _Delete your docker volumes or use a unique project (-p) name_ Start DSpace with Database Content from a database dump ``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d +docker-compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d ``` Load assetstore content and trigger a re-index of the repository ``` -docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli +docker-compose -p d8 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli ``` ## End to end testing of the REST API (runs in GitHub Actions CI). @@ -140,5 +140,5 @@ _In this instance, only the REST api runs in Docker using the Entities dataset. This command is only really useful for testing our Continuous Integration process. ``` -docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d +docker-compose -p d8ci -f docker/docker-compose-ci.yml up -d ``` diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 31bc53f64d..98f7414861 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -12,8 +12,6 @@ # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.assetstore.yml # # Therefore, it should be kept in sync with that file -version: "3.7" - services: dspace-cli: environment: diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml index 1db241af3b..31563ccc08 100644 --- a/docker/cli.ingest.yml +++ b/docker/cli.ingest.yml @@ -12,8 +12,6 @@ # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.ingest.yml # # Therefore, it should be kept in sync with that file -version: "3.7" - services: dspace-cli: environment: @@ -34,5 +32,7 @@ services: /dspace/bin/dspace packager -r -a -t AIP -e $${ADMIN_EMAIL} -f -u SITE*.zip /dspace/bin/dspace database update-sequences + touch /dspace/solr/search/conf/reindex.flag - /dspace/bin/dspace index-discovery + /dspace/bin/dspace oai import + /dspace/bin/dspace oai clean-cache diff --git a/docker/cli.yml b/docker/cli.yml index cc266b186f..9b1973426f 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -12,7 +12,6 @@ # https://github.com/DSpace/DSpace/blob/main/docker-compose-cli.yml # # Therefore, it should be kept in sync with that file -version: "3.7" networks: # Default to using network named 'dspacenet' from docker-compose-rest.yml. # Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet") diff --git a/docker/db.entities.yml b/docker/db.entities.yml index 6473bf2e38..ae1ac77346 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -12,11 +12,9 @@ # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # # # Therefore, it should be kept in sync with that file -version: "3.7" - services: dspacedb: - image: dspace/dspace-postgres-pgcrypto:loadsql + image: dspace/dspace-postgres-pgcrypto::${DSPACE_VER:-latest}-loadsql environment: # This LOADSQL should be kept in sync with the URL in DSpace/DSpace # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data @@ -29,23 +27,11 @@ services: # 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml # This 'sed' command inserts the sample configurations specific to the Entities data set, see: # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49 - # 4. Finally, start Tomcat + # 4. Finally, start DSpace entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate ignored - sed -i '/name-map collection-handle="default".*/a \\n \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - ' /dspace/config/item-submission.xml - catalina.sh run \ No newline at end of file + java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace \ No newline at end of file diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 07993e20c6..c5c419a4a7 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -10,7 +10,6 @@ # This is used by our GitHub CI at .github/workflows/build.yml # It is based heavily on the Backend's Docker Compose: # https://github.com/DSpace/DSpace/blob/main/docker-compose.yml -version: '3.7' networks: dspacenet: services: @@ -33,6 +32,7 @@ services: # Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. solr__D__statistics__P__autoCommit: 'false' + LOGGING_CONFIG: /dspace/config/log4j2-container.xml image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb @@ -48,27 +48,31 @@ services: # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) - # 3. Finally, start Tomcat + # 3. Finally, start DSpace entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate ignored - catalina.sh run + java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace # DSpace database container # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data dspacedb: container_name: dspacedb + image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql" environment: # This LOADSQL should be kept in sync with the LOADSQL in # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql PGDATA: /pgdata - image: dspace/dspace-postgres-pgcrypto:loadsql + POSTGRES_PASSWORD: dspace networks: - dspacenet + ports: + - published: 5432 + target: 5432 stdin_open: true tty: true volumes: @@ -105,6 +109,8 @@ services: cp -r /opt/solr/server/solr/configsets/statistics/* statistics precreate-core qaevent /opt/solr/server/solr/configsets/qaevent cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent + precreate-core suggestion /opt/solr/server/solr/configsets/suggestion + cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion exec solr -f volumes: assetstore: diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml index 38278085cd..67eba16785 100644 --- a/docker/docker-compose-dist.yml +++ b/docker/docker-compose-dist.yml @@ -8,7 +8,6 @@ # Docker Compose for running the DSpace Angular UI dist build # for previewing with the DSpace Demo site backend -version: '3.7' networks: dspacenet: services: diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index e1577ec837..c04948e4b2 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -10,7 +10,6 @@ # This is based heavily on the docker-compose.yml that is available in the DSpace/DSpace # (Backend) at: # https://github.com/DSpace/DSpace/blob/main/docker-compose.yml -version: '3.7' networks: dspacenet: ipam: @@ -29,8 +28,9 @@ services: # __D__ => "-" (e.g. google__D__metadata => google-metadata) # dspace.dir, dspace.server.url, dspace.ui.url and dspace.name dspace__P__dir: /dspace - dspace__P__server__P__url: http://localhost:8080/server - dspace__P__ui__P__url: http://localhost:4000 + # Uncomment to set a non-default value for dspace.server.url or dspace.ui.url + # dspace__P__server__P__url: http://localhost:8080/server + # dspace__P__ui__P__url: http://localhost:4000 dspace__P__name: 'DSpace Started with Docker Compose' # db.url: Ensure we are using the 'dspacedb' image for our database db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' @@ -39,6 +39,7 @@ services: # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. proxies__P__trusted__P__ipranges: '172.23.0' + LOGGING_CONFIG: /dspace/config/log4j2-container.xml image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb @@ -50,24 +51,27 @@ services: stdin_open: true tty: true volumes: + # Keep DSpace assetstore directory between reboots - assetstore:/dspace/assetstore # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables - # 3. Finally, start Tomcat + # 3. Finally, start DSpace entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate - catalina.sh run + java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace # DSpace database container dspacedb: container_name: dspacedb + # Uses a custom Postgres image with pgcrypto installed + image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" environment: PGDATA: /pgdata - image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" + POSTGRES_PASSWORD: dspace networks: - dspacenet ports: @@ -113,6 +117,8 @@ services: cp -r /opt/solr/server/solr/configsets/statistics/* statistics precreate-core qaevent /opt/solr/server/solr/configsets/qaevent cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent + precreate-core suggestion /opt/solr/server/solr/configsets/suggestion + cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion exec solr -f volumes: assetstore: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1071b8d6ce..1c268b84b7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,7 +9,6 @@ # Docker Compose for running the DSpace Angular UI for testing/development # Requires also running a REST API backend (either locally or remotely), # for example via 'docker-compose-rest.yml' -version: '3.7' networks: dspacenet: services: diff --git a/docs/lint/html/index.md b/docs/lint/html/index.md new file mode 100644 index 0000000000..15d693843c --- /dev/null +++ b/docs/lint/html/index.md @@ -0,0 +1,4 @@ +[DSpace ESLint plugins](../../../lint/README.md) > HTML rules +_______ + +- [`dspace-angular-html/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via the selector of their `ThemedComponent` wrapper class diff --git a/docs/lint/html/rules/themed-component-usages.md b/docs/lint/html/rules/themed-component-usages.md new file mode 100644 index 0000000000..a04fe1c770 --- /dev/null +++ b/docs/lint/html/rules/themed-component-usages.md @@ -0,0 +1,110 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/themed-component-usages` +_______ + +Themeable components should be used via the selector of their `ThemedComponent` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple. + + +_______ + +[Source code](../../../../lint/src/rules/html/themed-component-usages.ts) + +### Examples + + +#### Valid code + +##### use no-prefix selectors in HTML templates + +```html + + + +``` + +##### use no-prefix selectors in TypeScript templates + +```html +@Component({ + template: '' +}) +class Test { +} +``` + +##### use no-prefix selectors in TypeScript test templates + +Filename: `lint/test/fixture/src/test.spec.ts` + +```html +@Component({ + template: '' +}) +class Test { +} +``` + +##### base selectors are also allowed in TypeScript test templates + +Filename: `lint/test/fixture/src/test.spec.ts` + +```html +@Component({ + template: '' +}) +class Test { +} +``` + + + + +#### Invalid code & automatic fixes + +##### themed override selectors are not allowed in HTML templates + +```html + + + +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +``` + +Result of `yarn lint --fix`: +```html + + + +``` + + +##### base selectors are not allowed in HTML templates + +```html + + + +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +``` + +Result of `yarn lint --fix`: +```html + + + +``` + + + diff --git a/docs/lint/ts/index.md b/docs/lint/ts/index.md new file mode 100644 index 0000000000..ed060c946e --- /dev/null +++ b/docs/lint/ts/index.md @@ -0,0 +1,6 @@ +[DSpace ESLint plugins](../../../lint/README.md) > TypeScript rules +_______ + +- [`dspace-angular-ts/themed-component-classes`](./rules/themed-component-classes.md): Formatting rules for themeable component classes +- [`dspace-angular-ts/themed-component-selectors`](./rules/themed-component-selectors.md): Themeable component selectors should follow the DSpace convention +- [`dspace-angular-ts/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via their `ThemedComponent` wrapper class diff --git a/docs/lint/ts/rules/themed-component-classes.md b/docs/lint/ts/rules/themed-component-classes.md new file mode 100644 index 0000000000..1f4ec72801 --- /dev/null +++ b/docs/lint/ts/rules/themed-component-classes.md @@ -0,0 +1,257 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-classes` +_______ + +Formatting rules for themeable component classes + +- All themeable components must be standalone. +- The base component must always be imported in the `ThemedComponent` wrapper. This ensures that it is always sufficient to import just the wrapper whenever we use the component. + + +_______ + +[Source code](../../../../lint/src/rules/ts/themed-component-classes.ts) + +### Examples + + +#### Valid code + +##### Regular non-themeable component + +```typescript +@Component({ + selector: 'ds-something', + standalone: true, +}) +class Something { +} +``` + +##### Base component + +```typescript +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableTomponent { +} +``` + +##### Wrapper component + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + TestThemeableComponent, + ], +}) +class ThemedTestThemeableTomponent extends ThemedComponent { +} +``` + +##### Override component + +Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} +``` + + + + +#### Invalid code & automatic fixes + +##### Base component must be standalone + +```typescript +@Component({ + selector: 'ds-base-test-themable', +}) +class TestThemeableComponent { +} +``` +Will produce the following error(s): +``` +Themeable components must be standalone +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableComponent { +} +``` + + +##### Wrapper component must be standalone and import base component + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-test-themable', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themeable component wrapper classes must be standalone and import the base class +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrapper component must import base component (array present but empty) + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper classes must only import the base class +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrapper component must import base component (array is wrong) + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper classes must only import the base class +``` + +Result of `yarn lint --fix`: +```typescript +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrapper component must import base component (array is wrong) + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper classes must only import the base class +``` + +Result of `yarn lint --fix`: +```typescript +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Override component must be standalone + +Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-themed-test-themable', +}) +class Override extends BaseComponent { +} +``` +Will produce the following error(s): +``` +Themeable components must be standalone +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} +``` + + + diff --git a/docs/lint/ts/rules/themed-component-selectors.md b/docs/lint/ts/rules/themed-component-selectors.md new file mode 100644 index 0000000000..f4d0ea177c --- /dev/null +++ b/docs/lint/ts/rules/themed-component-selectors.md @@ -0,0 +1,156 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-selectors` +_______ + +Themeable component selectors should follow the DSpace convention + +Each themeable component is comprised of a base component, a wrapper component and any number of themed components +- Base components should have a selector starting with `ds-base-` +- Themed components should have a selector starting with `ds-themed-` +- Wrapper components should have a selector starting with `ds-`, but not `ds-base-` or `ds-themed-` + - This is the regular DSpace selector prefix + - **When making a regular component themeable, its selector prefix should be changed to `ds-base-`, and the new wrapper's component should reuse the previous selector** + +Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source. + + +_______ + +[Source code](../../../../lint/src/rules/ts/themed-component-selectors.ts) + +### Examples + + +#### Valid code + +##### Regular non-themeable component selector + +```typescript +@Component({ + selector: 'ds-something', +}) +class Something { +} +``` + +##### Themeable component selector should replace the original version, unthemed version should be changed to ds-base- + +```typescript +@Component({ + selector: 'ds-base-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something', +}) +class ThemedSomething extends ThemedComponent { +} + +@Component({ + selector: 'ds-themed-something', +}) +class OverrideSomething extends Something { +} +``` + +##### Other themed component wrappers should not interfere + +```typescript +@Component({ + selector: 'ds-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something-else', +}) +class ThemedSomethingElse extends ThemedComponent { +} +``` + + + + +#### Invalid code & automatic fixes + +##### Wrong selector for base component + +Filename: `lint/test/fixture/src/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-something', +}) +class TestThemeableComponent { +} +``` +Will produce the following error(s): +``` +Unthemed version of themeable component should have a selector starting with 'ds-base-' +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-base-something', +}) +class TestThemeableComponent { +} +``` + + +##### Wrong selector for wrapper component + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-themed-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper of themeable component shouldn't have a selector starting with 'ds-themed-' +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrong selector for theme override + +Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-something', +}) +class TestThememeableComponent extends BaseComponent { +} +``` +Will produce the following error(s): +``` +Theme override of themeable component should have a selector starting with 'ds-themed-' +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-themed-something', +}) +class TestThememeableComponent extends BaseComponent { +} +``` + + + diff --git a/docs/lint/ts/rules/themed-component-usages.md b/docs/lint/ts/rules/themed-component-usages.md new file mode 100644 index 0000000000..16ccb701c2 --- /dev/null +++ b/docs/lint/ts/rules/themed-component-usages.md @@ -0,0 +1,332 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-usages` +_______ + +Themeable components should be used via their `ThemedComponent` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +There are a few exceptions where the base class can still be used: +- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place) +- Angular modules (except for routing modules) +- Angular `@ViewChild` decorators +- Type annotations + + +_______ + +[Source code](../../../../lint/src/rules/ts/themed-component-usages.ts) + +### Examples + + +#### Valid code + +##### allow wrapper class usages + +```typescript +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: ChipsComponent, +} +``` + +##### allow base class in class declaration + +```typescript +export class TestThemeableComponent { +} +``` + +##### allow inheriting from base class + +```typescript +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class ThemedAdminSidebarComponent extends ThemedComponent { +} +``` + +##### allow base class in ViewChild + +```typescript +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class Something { + @ViewChild(TestThemeableComponent) test: TestThemeableComponent; +} +``` + +##### allow wrapper selectors in test queries + +Filename: `lint/test/fixture/src/app/test/test.component.spec.ts` + +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + +##### allow wrapper selectors in cypress queries + +Filename: `lint/test/fixture/src/app/test/test.component.cy.ts` + +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + + + + +#### Invalid code & automatic fixes + +##### disallow direct usages of base class + +```typescript +import { TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, +} +``` + + +##### disallow direct usages of base class, keep other imports + +```typescript +import { Something, TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, + c: Something, +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { Something } from './app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, + c: Something, +} +``` + + +##### handle array replacements correctly + +```typescript +const DECLARATIONS = [ + Something, + TestThemeableComponent, + Something, + ThemedTestThemeableComponent, +]; +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +const DECLARATIONS = [ + Something, + Something, + ThemedTestThemeableComponent, +]; +``` + + +##### disallow override selector in test queries + +Filename: `lint/test/fixture/src/app/test/test.component.spec.ts` + +```typescript +By.css('ds-themed-themeable'); +By.css('#test > ds-themed-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + + +##### disallow base selector in test queries + +Filename: `lint/test/fixture/src/app/test/test.component.spec.ts` + +```typescript +By.css('ds-base-themeable'); +By.css('#test > ds-base-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + + +##### disallow override selector in cypress queries + +Filename: `lint/test/fixture/src/app/test/test.component.cy.ts` + +```typescript +cy.get('ds-themed-themeable'); +cy.get('#test > ds-themed-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); +``` + + +##### disallow base selector in cypress queries + +Filename: `lint/test/fixture/src/app/test/test.component.cy.ts` + +```typescript +cy.get('ds-base-themeable'); +cy.get('#test > ds-base-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); +``` + + +##### edge case: unable to find usage node through usage token, but import is still flagged and fixed + +Filename: `lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts` + +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent], +}) +export class UsageComponent { +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} +``` + + +##### edge case edge case: both are imported, only wrapper is retained + +Filename: `lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts` + +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent, ThemedTestThemeableComponent], +}) +export class UsageComponent { +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} +``` + + + diff --git a/karma.conf.js b/karma.conf.js index 8418312b1a..f96558bfaf 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,10 @@ module.exports = function (config) { ], client: { clearContext: false, // leave Jasmine Spec Runner output visible in browser - captureConsole: false + captureConsole: false, + jasmine: { + failSpecWithNoExpectations: true + } }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/dspace-angular'), diff --git a/lint/.gitignore b/lint/.gitignore new file mode 100644 index 0000000000..0d22081b3b --- /dev/null +++ b/lint/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/coverage/ +/node-modules/ diff --git a/lint/README.md b/lint/README.md new file mode 100644 index 0000000000..7251a35c06 --- /dev/null +++ b/lint/README.md @@ -0,0 +1,50 @@ +# DSpace ESLint plugins + +Custom ESLint rules for DSpace Angular peculiarities. + +## Usage + +These plugins are included with the rest of our ESLint configuration in [.eslintc.json](../.eslintrc.json). Individual rules can be configured or disabled there, like usual. +- In order for the new rules to be picked up by your IDE, you should first run `yarn build:lint` to build the plugins. +- This will also happen automatically each time `yarn lint` is run. + +## Documentation + +The rules are split up into plugins by language: +- [TypeScript rules](../docs/lint/ts/index.md) +- [HTML rules](../docs/lint/html/index.md) + +> Run `yarn docs:lint` to generate this documentation! + +## Developing + +### Overview + +- All rules are written in TypeScript and compiled into [`dist`](./dist) + - The plugins are linked into the main project dependencies from here + - These directories already contain the necessary `package.json` files to mark them as ESLint plugins +- Rule source files are structured, so they can be imported all in one go + - Each rule must export the following: + - `Messages`: an Enum of error message IDs + - `info`: metadata about this rule (name, description, messages, options, ...) + - `rule`: the implementation of the rule + - `tests`: the tests for this rule, as a set of valid/invalid code snippets. These snippets are used as example in the documentation. + - New rules should be added to their plugin's `index.ts` +- Some useful links + - [Developing ESLint plugins](https://eslint.org/docs/latest/extend/plugins) + - [Custom rules in typescript-eslint](https://typescript-eslint.io/developers/custom-rules) + - [Angular ESLint](https://github.com/angular-eslint/angular-eslint) + +### Parsing project metadata in advance ~ TypeScript AST + +While it is possible to retain persistent state between files during the linting process, it becomes quite complicated if the content of one file determines how we want to lint another file. +Because the two files may be linted out of order, we may not know whether the first file is wrong before we pass by the second. This means that we cannot report or fix the issue, because the first file is already detached from the linting context. + +For example, we cannot consistently determine which components are themeable (i.e. have a `ThemedComponent` wrapper) while linting. +To work around this issue, we construct a registry of themeable components _before_ linting anything. +- We don't have a good way to hook into the ESLint parser at this time +- Instead, we leverage the actual TypeScript AST parser + - Retrieve all `ThemedComponent` wrapper files by the pattern of their path (`themed-*.component.ts`) + - Determine the themed component they're linked to (by the actual type annotation/import path, since filenames are prone to errors) + - Store metadata describing these component pairs in a global registry that can be shared between rules +- This only needs to happen once, and only takes a fraction of a second (for ~100 themeable components) \ No newline at end of file diff --git a/lint/dist/src/rules/html/package.json b/lint/dist/src/rules/html/package.json new file mode 100644 index 0000000000..d3f310d23b --- /dev/null +++ b/lint/dist/src/rules/html/package.json @@ -0,0 +1,6 @@ +{ + "name": "eslint-plugin-dspace-angular-html", + "version": "0.0.0", + "main": "./index.js", + "private": true +} diff --git a/lint/dist/src/rules/ts/package.json b/lint/dist/src/rules/ts/package.json new file mode 100644 index 0000000000..f19e18756a --- /dev/null +++ b/lint/dist/src/rules/ts/package.json @@ -0,0 +1,6 @@ +{ + "name": "eslint-plugin-dspace-angular-ts", + "version": "0.0.0", + "main": "./index.js", + "private": true +} diff --git a/lint/generate-docs.ts b/lint/generate-docs.ts new file mode 100644 index 0000000000..fb2bf53fb5 --- /dev/null +++ b/lint/generate-docs.ts @@ -0,0 +1,85 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { join } from 'path'; + +import { default as htmlPlugin } from './src/rules/html'; +import { default as tsPlugin } from './src/rules/ts'; + +const templates = new Map(); + +function lazyEJS(path: string, data: object): string { + if (!templates.has(path)) { + templates.set(path, require('ejs').compile(readFileSync(path).toString())); + } + + return templates.get(path)(data).replace(/\r\n/g, '\n'); +} + +const docsDir = join('docs', 'lint'); +const tsDir = join(docsDir, 'ts'); +const htmlDir = join(docsDir, 'html'); + +if (existsSync(docsDir)) { + rmSync(docsDir, { recursive: true }); +} + +mkdirSync(join(tsDir, 'rules'), { recursive: true }); +mkdirSync(join(htmlDir, 'rules'), { recursive: true }); + +function template(name: string): string { + return join('lint', 'src', 'util', 'templates', name); +} + +// TypeScript docs +writeFileSync( + join(tsDir, 'index.md'), + lazyEJS(template('index.ejs'), { + plugin: tsPlugin, + rules: tsPlugin.index.map(rule => rule.info), + }), +); + +for (const rule of tsPlugin.index) { + writeFileSync( + join(tsDir, 'rules', rule.info.name + '.md'), + lazyEJS(template('rule.ejs'), { + plugin: tsPlugin, + rule: rule.info, + tests: rule.tests, + }), + ); +} + +// HTML docs +writeFileSync( + join(htmlDir, 'index.md'), + lazyEJS(template('index.ejs'), { + plugin: htmlPlugin, + rules: htmlPlugin.index.map(rule => rule.info), + }), +); + +for (const rule of htmlPlugin.index) { + writeFileSync( + join(htmlDir, 'rules', rule.info.name + '.md'), + lazyEJS(template('rule.ejs'), { + plugin: htmlPlugin, + rule: rule.info, + tests: rule.tests, + }), + ); +} + diff --git a/lint/jasmine.json b/lint/jasmine.json new file mode 100644 index 0000000000..dfacd41a96 --- /dev/null +++ b/lint/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_files": ["**/*.spec.js"], + "spec_dir": "lint/dist/test", + "helpers": [ + "./test/helpers.js" + ] +} diff --git a/lint/src/rules/html/index.ts b/lint/src/rules/html/index.ts new file mode 100644 index 0000000000..7c1370ae2d --- /dev/null +++ b/lint/src/rules/html/index.ts @@ -0,0 +1,22 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/* eslint-disable import/no-namespace */ +import { + bundle, + RuleExports, +} from '../../util/structure'; +import * as themedComponentUsages from './themed-component-usages'; + +const index = [ + themedComponentUsages, +] as unknown as RuleExports[]; + +export = { + parser: require('@angular-eslint/template-parser'), + ...bundle('dspace-angular-html', 'HTML', index), +}; diff --git a/lint/src/rules/html/themed-component-usages.ts b/lint/src/rules/html/themed-component-usages.ts new file mode 100644 index 0000000000..0b9a13456a --- /dev/null +++ b/lint/src/rules/html/themed-component-usages.ts @@ -0,0 +1,191 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler'; +import { TemplateParserServices } from '@angular-eslint/utils'; +import { + ESLintUtils, + TSESLint, +} from '@typescript-eslint/utils'; + +import { fixture } from '../../../test/fixture'; +import { + DSpaceESLintRuleInfo, + NamedTests, +} from '../../util/structure'; +import { + DISALLOWED_THEME_SELECTORS, + fixSelectors, +} from '../../util/theme-support'; +import { + getFilename, + getSourceCode, +} from '../../util/typescript'; + +export enum Message { + WRONG_SELECTOR = 'mustUseThemedWrapperSelector', +} + +export const info = { + name: 'themed-component-usages', + meta: { + docs: { + description: `Themeable components should be used via the selector of their \`ThemedComponent\` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple. + `, + }, + type: 'problem', + fixable: 'code', + schema: [], + messages: { + [Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper\'s selector', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext) { + if (getFilename(context).includes('.spec.ts')) { + // skip inline templates in unit tests + return {}; + } + + const parserServices = getSourceCode(context).parserServices as TemplateParserServices; + + return { + [`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: TmplAstElement) { + const { startSourceSpan, endSourceSpan } = node; + const openStart = startSourceSpan.start.offset as number; + + context.report({ + messageId: Message.WRONG_SELECTOR, + loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan), + fix(fixer) { + const oldSelector = node.name; + const newSelector = fixSelectors(oldSelector); + + const ops = [ + fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector), + ]; + + // make sure we don't mangle self-closing tags + if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) { + const closeStart = endSourceSpan.start.offset as number; + const closeEnd = endSourceSpan.end.offset as number; + + ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector)); + } + + return ops; + }, + }); + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'use no-prefix selectors in HTML templates', + code: ` + + + + `, + }, + { + name: 'use no-prefix selectors in TypeScript templates', + code: ` +@Component({ + template: '' +}) +class Test { +} + `, + }, + { + name: 'use no-prefix selectors in TypeScript test templates', + filename: fixture('src/test.spec.ts'), + code: ` +@Component({ + template: '' +}) +class Test { +} + `, + }, + { + name: 'base selectors are also allowed in TypeScript test templates', + filename: fixture('src/test.spec.ts'), + code: ` +@Component({ + template: '' +}) +class Test { +} + `, + }, + ], + invalid: [ + { + name: 'themed override selectors are not allowed in HTML templates', + code: ` + + + + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` + + + + `, + }, + { + name: 'base selectors are not allowed in HTML templates', + code: ` + + + + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` + + + + `, + }, + ], +} as NamedTests; + +export default rule; diff --git a/lint/src/rules/ts/index.ts b/lint/src/rules/ts/index.ts new file mode 100644 index 0000000000..a7fdfe41ef --- /dev/null +++ b/lint/src/rules/ts/index.ts @@ -0,0 +1,25 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + bundle, + RuleExports, +} from '../../util/structure'; +/* eslint-disable import/no-namespace */ +import * as themedComponentClasses from './themed-component-classes'; +import * as themedComponentSelectors from './themed-component-selectors'; +import * as themedComponentUsages from './themed-component-usages'; + +const index = [ + themedComponentClasses, + themedComponentSelectors, + themedComponentUsages, +] as unknown as RuleExports[]; + +export = { + ...bundle('dspace-angular-ts', 'TypeScript', index), +}; diff --git a/lint/src/rules/ts/themed-component-classes.ts b/lint/src/rules/ts/themed-component-classes.ts new file mode 100644 index 0000000000..66c37395b4 --- /dev/null +++ b/lint/src/rules/ts/themed-component-classes.ts @@ -0,0 +1,382 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { fixture } from '../../../test/fixture'; +import { + getComponentImportNode, + getComponentInitializer, + getComponentStandaloneNode, +} from '../../util/angular'; +import { appendObjectProperties } from '../../util/fix'; +import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + getBaseComponentClassName, + inThemedComponentOverrideFile, + isThemeableComponent, + isThemedComponentWrapper, +} from '../../util/theme-support'; +import { getFilename } from '../../util/typescript'; + +export enum Message { + NOT_STANDALONE = 'mustBeStandalone', + NOT_STANDALONE_IMPORTS_BASE = 'mustBeStandaloneAndImportBase', + WRAPPER_IMPORTS_BASE = 'wrapperShouldImportBase', +} + +export const info = { + name: 'themed-component-classes', + meta: { + docs: { + description: `Formatting rules for themeable component classes + +- All themeable components must be standalone. +- The base component must always be imported in the \`ThemedComponent\` wrapper. This ensures that it is always sufficient to import just the wrapper whenever we use the component. + `, + }, + type: 'problem', + fixable: 'code', + schema: [], + messages: { + [Message.NOT_STANDALONE]: 'Themeable components must be standalone', + [Message.NOT_STANDALONE_IMPORTS_BASE]: 'Themeable component wrapper classes must be standalone and import the base class', + [Message.WRAPPER_IMPORTS_BASE]: 'Themed component wrapper classes must only import the base class', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext) { + const filename = getFilename(context); + + if (filename.endsWith('.spec.ts')) { + return {}; + } + + function enforceStandalone(decoratorNode: TSESTree.Decorator, withBaseImport = false) { + const standaloneNode = getComponentStandaloneNode(decoratorNode); + + if (standaloneNode === undefined) { + // We may need to add these properties in one go + if (!withBaseImport) { + context.report({ + messageId: Message.NOT_STANDALONE, + node: decoratorNode, + fix(fixer) { + const initializer = getComponentInitializer(decoratorNode); + return appendObjectProperties(context, fixer, initializer, ['standalone: true']); + }, + }); + } + } else if (!standaloneNode.value) { + context.report({ + messageId: Message.NOT_STANDALONE, + node: standaloneNode, + fix(fixer) { + return fixer.replaceText(standaloneNode, 'true'); + }, + }); + } + + if (withBaseImport) { + const baseClass = getBaseComponentClassName(decoratorNode); + + if (baseClass === undefined) { + return; + } + + const importsNode = getComponentImportNode(decoratorNode); + + if (importsNode === undefined) { + if (standaloneNode === undefined) { + context.report({ + messageId: Message.NOT_STANDALONE_IMPORTS_BASE, + node: decoratorNode, + fix(fixer) { + const initializer = getComponentInitializer(decoratorNode); + return appendObjectProperties(context, fixer, initializer, ['standalone: true', `imports: [${baseClass}]`]); + }, + }); + } else { + context.report({ + messageId: Message.WRAPPER_IMPORTS_BASE, + node: decoratorNode, + fix(fixer) { + const initializer = getComponentInitializer(decoratorNode); + return appendObjectProperties(context, fixer, initializer, [`imports: [${baseClass}]`]); + }, + }); + } + } else { + // If we have an imports node, standalone: true will be enforced by another rule + + const imports = importsNode.elements.map(e => (e as TSESTree.Identifier).name); + + if (!imports.includes(baseClass) || imports.length > 1) { + // The wrapper should _only_ import the base component + context.report({ + messageId: Message.WRAPPER_IMPORTS_BASE, + node: importsNode, + fix(fixer) { + // todo: this may leave unused imports, but that's better than mangling things + return fixer.replaceText(importsNode, `[${baseClass}]`); + }, + }); + } + } + } + } + + return { + 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) { + const classNode = node.parent as TSESTree.ClassDeclaration; + const className = classNode.id?.name; + + if (className === undefined) { + return; + } + + if (isThemedComponentWrapper(node)) { + enforceStandalone(node, true); + } else if (inThemedComponentOverrideFile(filename)) { + enforceStandalone(node); + } else if (isThemeableComponent(className)) { + enforceStandalone(node); + } + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'Regular non-themeable component', + code: ` +@Component({ + selector: 'ds-something', + standalone: true, +}) +class Something { +} + `, + }, + { + name: 'Base component', + code: ` +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableTomponent { +} + `, + }, + { + name: 'Wrapper component', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + TestThemeableComponent, + ], +}) +class ThemedTestThemeableTomponent extends ThemedComponent { +} + `, + }, + { + name: 'Override component', + filename: fixture('src/themes/test/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} + `, + }, + ], + invalid: [ + { + name: 'Base component must be standalone', + code: ` +@Component({ + selector: 'ds-base-test-themable', +}) +class TestThemeableComponent { +} + `, + errors:[ + { + messageId: Message.NOT_STANDALONE, + }, + ], + output: ` +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableComponent { +} + `, + }, + { + name: 'Wrapper component must be standalone and import base component', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-test-themable', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.NOT_STANDALONE_IMPORTS_BASE, + }, + ], + output: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + + { + name: 'Wrapper component must import base component (array present but empty)', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.WRAPPER_IMPORTS_BASE, + }, + ], + output: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + { + name: 'Wrapper component must import base component (array is wrong)', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.WRAPPER_IMPORTS_BASE, + }, + ], + output: ` +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, { + name: 'Wrapper component must import base component (array is wrong)', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.WRAPPER_IMPORTS_BASE, + }, + ], + output: ` +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + { + name: 'Override component must be standalone', + filename: fixture('src/themes/test/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-themed-test-themable', +}) +class Override extends BaseComponent { +} + `, + errors:[ + { + messageId: Message.NOT_STANDALONE, + }, + ], + output: ` +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} + `, + }, + ], +}; diff --git a/lint/src/rules/ts/themed-component-selectors.ts b/lint/src/rules/ts/themed-component-selectors.ts new file mode 100644 index 0000000000..e06f5ababf --- /dev/null +++ b/lint/src/rules/ts/themed-component-selectors.ts @@ -0,0 +1,257 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { fixture } from '../../../test/fixture'; +import { getComponentSelectorNode } from '../../util/angular'; +import { stringLiteral } from '../../util/misc'; +import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + inThemedComponentOverrideFile, + isThemeableComponent, + isThemedComponentWrapper, +} from '../../util/theme-support'; +import { getFilename } from '../../util/typescript'; + +export enum Message { + BASE = 'wrongSelectorUnthemedComponent', + WRAPPER = 'wrongSelectorThemedComponentWrapper', + THEMED = 'wrongSelectorThemedComponentOverride', +} + +export const info = { + name: 'themed-component-selectors', + meta: { + docs: { + description: `Themeable component selectors should follow the DSpace convention + +Each themeable component is comprised of a base component, a wrapper component and any number of themed components +- Base components should have a selector starting with \`ds-base-\` +- Themed components should have a selector starting with \`ds-themed-\` +- Wrapper components should have a selector starting with \`ds-\`, but not \`ds-base-\` or \`ds-themed-\` + - This is the regular DSpace selector prefix + - **When making a regular component themeable, its selector prefix should be changed to \`ds-base-\`, and the new wrapper's component should reuse the previous selector** + +Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source. + `, + }, + type: 'problem', + schema: [], + fixable: 'code', + messages: { + [Message.BASE]: 'Unthemed version of themeable component should have a selector starting with \'ds-base-\'', + [Message.WRAPPER]: 'Themed component wrapper of themeable component shouldn\'t have a selector starting with \'ds-themed-\'', + [Message.THEMED]: 'Theme override of themeable component should have a selector starting with \'ds-themed-\'', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext) { + const filename = getFilename(context); + + if (filename.endsWith('.spec.ts')) { + return {}; + } + + function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) { + if (selectorNode?.value.startsWith('ds-themed-')) { + context.report({ + messageId: Message.WRAPPER, + node: selectorNode, + fix(fixer) { + return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-'))); + }, + }); + } + } + + function enforceBaseSelector(selectorNode: TSESTree.StringLiteral) { + if (!selectorNode?.value.startsWith('ds-base-')) { + context.report({ + messageId: Message.BASE, + node: selectorNode, + fix(fixer) { + return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-'))); + }, + }); + } + } + + function enforceThemedSelector(selectorNode: TSESTree.StringLiteral) { + if (!selectorNode?.value.startsWith('ds-themed-')) { + context.report({ + messageId: Message.THEMED, + node: selectorNode, + fix(fixer) { + return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-'))); + }, + }); + } + } + + return { + 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) { + const selectorNode = getComponentSelectorNode(node); + + if (selectorNode === undefined) { + return; + } + + const selector = selectorNode?.value; + const classNode = node.parent as TSESTree.ClassDeclaration; + const className = classNode.id?.name; + + if (selector === undefined || className === undefined) { + return; + } + + if (isThemedComponentWrapper(node)) { + enforceWrapperSelector(selectorNode); + } else if (inThemedComponentOverrideFile(filename)) { + enforceThemedSelector(selectorNode); + } else if (isThemeableComponent(className)) { + enforceBaseSelector(selectorNode); + } + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'Regular non-themeable component selector', + code: ` +@Component({ + selector: 'ds-something', +}) +class Something { +} + `, + }, + { + name: 'Themeable component selector should replace the original version, unthemed version should be changed to ds-base-', + code: ` +@Component({ + selector: 'ds-base-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something', +}) +class ThemedSomething extends ThemedComponent { +} + +@Component({ + selector: 'ds-themed-something', +}) +class OverrideSomething extends Something { +} + `, + }, + { + name: 'Other themed component wrappers should not interfere', + code: ` +@Component({ + selector: 'ds-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something-else', +}) +class ThemedSomethingElse extends ThemedComponent { +} + `, + }, + ], + invalid: [ + { + name: 'Wrong selector for base component', + filename: fixture('src/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-something', +}) +class TestThemeableComponent { +} + `, + errors: [ + { + messageId: Message.BASE, + }, + ], + output: ` +@Component({ + selector: 'ds-base-something', +}) +class TestThemeableComponent { +} + `, + }, + { + name: 'Wrong selector for wrapper component', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-themed-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors: [ + { + messageId: Message.WRAPPER, + }, + ], + output: ` +@Component({ + selector: 'ds-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + { + name: 'Wrong selector for theme override', + filename: fixture('src/themes/test/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-something', +}) +class TestThememeableComponent extends BaseComponent { +} + `, + errors: [ + { + messageId: Message.THEMED, + }, + ], + output: ` +@Component({ + selector: 'ds-themed-something', +}) +class TestThememeableComponent extends BaseComponent { +} + `, + }, + ], +}; + +export default rule; diff --git a/lint/src/rules/ts/themed-component-usages.ts b/lint/src/rules/ts/themed-component-usages.ts new file mode 100644 index 0000000000..96e9962ccf --- /dev/null +++ b/lint/src/rules/ts/themed-component-usages.ts @@ -0,0 +1,502 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { fixture } from '../../../test/fixture'; +import { + removeWithCommas, + replaceOrRemoveArrayIdentifier, +} from '../../util/fix'; +import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + allThemeableComponents, + DISALLOWED_THEME_SELECTORS, + fixSelectors, + getThemeableComponentByBaseClass, + isAllowedUnthemedUsage, +} from '../../util/theme-support'; +import { + findImportSpecifier, + findUsages, + findUsagesByName, + getFilename, + relativePath, +} from '../../util/typescript'; + +export enum Message { + WRONG_CLASS = 'mustUseThemedWrapperClass', + WRONG_IMPORT = 'mustImportThemedWrapper', + WRONG_SELECTOR = 'mustUseThemedWrapperSelector', + BASE_IN_MODULE = 'baseComponentNotNeededInModule', +} + +export const info = { + name: 'themed-component-usages', + meta: { + docs: { + description: `Themeable components should be used via their \`ThemedComponent\` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +There are a few exceptions where the base class can still be used: +- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place) +- Angular modules (except for routing modules) +- Angular \`@ViewChild\` decorators +- Type annotations + `, + }, + type: 'problem', + schema: [], + fixable: 'code', + messages: { + [Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper', + [Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper', + [Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper', + [Message.BASE_IN_MODULE]: 'Base themeable components shouldn\'t be declared in modules', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext) { + const filename = getFilename(context); + + function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) { + if (isAllowedUnthemedUsage(node)) { + return; + } + + const entry = getThemeableComponentByBaseClass(node.name); + + if (entry === undefined) { + // this should never happen + throw new Error(`No such themeable component in registry: '${node.name}'`); + } + + context.report({ + messageId: Message.WRONG_CLASS, + node: node, + fix(fixer) { + if (node.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) { + return replaceOrRemoveArrayIdentifier(context, fixer, node, entry.wrapperClass); + } else { + return fixer.replaceText(node, entry.wrapperClass); + } + }, + }); + } + + function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) { + context.report({ + node, + messageId: Message.WRONG_SELECTOR, + fix(fixer){ + const newSelector = fixSelectors(node.raw); + return fixer.replaceText(node, newSelector); + }, + }); + } + + function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) { + const allUsages = findUsages(context, specifierNode.local); + const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage)); + + if (badUsages.length === 0) { + return; + } + + const importedNode = specifierNode.imported; + const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration; + + const entry = getThemeableComponentByBaseClass(importedNode.name); + if (entry === undefined) { + // this should never happen + throw new Error(`No such themeable component in registry: '${importedNode.name}'`); + } + + context.report({ + messageId: Message.WRONG_IMPORT, + node: importedNode, + fix(fixer) { + const ops = []; + + const wrapperImport = findImportSpecifier(context, entry.wrapperClass); + + if (findUsagesByName(context, entry.wrapperClass).length === 0) { + // Wrapper is not present in this file, safe to add import + + const newImportLine = `import { ${entry.wrapperClass} } from '${relativePath(filename, entry.wrapperPath)}';`; + + if (declarationNode.specifiers.length === 1) { + if (allUsages.length === badUsages.length) { + ops.push(fixer.replaceText(declarationNode, newImportLine)); + } else if (wrapperImport === undefined) { + ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine)); + } + } else { + ops.push(...removeWithCommas(context, fixer, specifierNode)); + if (wrapperImport === undefined) { + ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine)); + } + } + } else { + // Wrapper already present in the file, remove import instead + + if (allUsages.length === badUsages.length) { + if (declarationNode.specifiers.length === 1) { + // Make sure we remove the newline as well + ops.push(fixer.removeRange([declarationNode.range[0], declarationNode.range[1] + 1])); + } else { + ops.push(...removeWithCommas(context, fixer, specifierNode)); + } + } + } + + return ops; + }, + }); + } + + // ignore tests and non-routing modules + if (filename.endsWith('.spec.ts')) { + return { + [`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, + }; + } else if (filename.endsWith('.cy.ts')) { + return { + [`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, + }; + } else if ( + filename.match(/(?!src\/themes\/).*(?!routing).module.ts$/) + || filename.match(/themed-.+\.component\.ts$/) + ) { + // do nothing + return {}; + } else { + return allThemeableComponents().reduce( + (rules, entry) => { + return { + ...rules, + [`:not(:matches(ClassDeclaration, ImportSpecifier)) > Identifier[name = "${entry.baseClass}"]`]: handleUnthemedUsagesInTypescript, + [`ImportSpecifier[imported.name = "${entry.baseClass}"]`]: handleUnthemedImportsInTypescript, + }; + }, {}, + ); + } + + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'allow wrapper class usages', + code: ` +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: ChipsComponent, +} + `, + }, + { + name: 'allow base class in class declaration', + code: ` +export class TestThemeableComponent { +} + `, + }, + { + name: 'allow inheriting from base class', + code: ` +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class ThemedAdminSidebarComponent extends ThemedComponent { +} + `, + }, + { + name: 'allow base class in ViewChild', + code: ` +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class Something { + @ViewChild(TestThemeableComponent) test: TestThemeableComponent; +} + `, + }, + { + name: 'allow wrapper selectors in test queries', + filename: fixture('src/app/test/test.component.spec.ts'), + code: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + { + name: 'allow wrapper selectors in cypress queries', + filename: fixture('src/app/test/test.component.cy.ts'), + code: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + ], + invalid: [ + { + name: 'disallow direct usages of base class', + code: ` +import { TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, +} + `, + }, + { + name: 'disallow direct usages of base class, keep other imports', + code: ` +import { Something, TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, + c: Something, +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { Something } from './app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, + c: Something, +} + `, + }, + { + name: 'handle array replacements correctly', + code: ` +const DECLARATIONS = [ + Something, + TestThemeableComponent, + Something, + ThemedTestThemeableComponent, +]; + `, + errors: [ + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +const DECLARATIONS = [ + Something, + Something, + ThemedTestThemeableComponent, +]; + `, + }, + { + name: 'disallow override selector in test queries', + filename: fixture('src/app/test/test.component.spec.ts'), + code: ` +By.css('ds-themed-themeable'); +By.css('#test > ds-themed-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + { + name: 'disallow base selector in test queries', + filename: fixture('src/app/test/test.component.spec.ts'), + code: ` +By.css('ds-base-themeable'); +By.css('#test > ds-base-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + { + name: 'disallow override selector in cypress queries', + filename: fixture('src/app/test/test.component.cy.ts'), + code: ` +cy.get('ds-themed-themeable'); +cy.get('#test > ds-themed-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); + `, + }, + { + name: 'disallow base selector in cypress queries', + filename: fixture('src/app/test/test.component.cy.ts'), + code: ` +cy.get('ds-base-themeable'); +cy.get('#test > ds-base-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); + `, + }, + { + name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed', + filename: fixture('src/themes/test/app/test/other-themeable.component.ts'), + code: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent], +}) +export class UsageComponent { +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} + `, + }, + { + name: 'edge case edge case: both are imported, only wrapper is retained', + filename: fixture('src/themes/test/app/test/other-themeable.component.ts'), + code: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent, ThemedTestThemeableComponent], +}) +export class UsageComponent { +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} + `, + }, + ], +}; + +export default rule; diff --git a/lint/src/util/angular.ts b/lint/src/util/angular.ts new file mode 100644 index 0000000000..70ee903fb8 --- /dev/null +++ b/lint/src/util/angular.ts @@ -0,0 +1,83 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TSESTree } from '@typescript-eslint/utils'; + +import { getObjectPropertyNodeByName } from './typescript'; + +export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined { + const property = getComponentInitializerNodeByName(componentDecoratorNode, 'selector'); + + if (property !== undefined) { + // todo: support template literals as well + if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'string') { + return property as TSESTree.StringLiteral; + } + } + + return undefined; +} + +export function getComponentStandaloneNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.BooleanLiteral | undefined { + const property = getComponentInitializerNodeByName(componentDecoratorNode, 'standalone'); + + if (property !== undefined) { + if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'boolean') { + return property as TSESTree.BooleanLiteral; + } + } + + return undefined; +} +export function getComponentImportNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.ArrayExpression | undefined { + const property = getComponentInitializerNodeByName(componentDecoratorNode, 'imports'); + + if (property !== undefined) { + if (property.type === TSESTree.AST_NODE_TYPES.ArrayExpression) { + return property as TSESTree.ArrayExpression; + } + } + + return undefined; +} + +export function getComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return undefined; + } + + if (decoratorNode.parent.id?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return undefined; + } + + return decoratorNode.parent.id.name; +} + +export function getComponentSuperClassName(decoratorNode: TSESTree.Decorator): string | undefined { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return undefined; + } + + if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return undefined; + } + + return decoratorNode.parent.superClass.name; +} + +export function getComponentInitializer(componentDecoratorNode: TSESTree.Decorator): TSESTree.ObjectExpression { + return (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression; +} + +export function getComponentInitializerNodeByName(componentDecoratorNode: TSESTree.Decorator, name: string): TSESTree.Node | undefined { + const initializer = getComponentInitializer(componentDecoratorNode); + return getObjectPropertyNodeByName(initializer, name); +} + +export function isPartOfViewChild(node: TSESTree.Identifier): boolean { + return (node.parent as any)?.callee?.name === 'ViewChild'; +} diff --git a/lint/src/util/fix.ts b/lint/src/util/fix.ts new file mode 100644 index 0000000000..10408cc316 --- /dev/null +++ b/lint/src/util/fix.ts @@ -0,0 +1,125 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TSESTree } from '@typescript-eslint/utils'; +import { + RuleContext, + RuleFix, + RuleFixer, +} from '@typescript-eslint/utils/ts-eslint'; + +import { getSourceCode } from './typescript'; + + + +export function appendObjectProperties(context: RuleContext, fixer: RuleFixer, objectNode: TSESTree.ObjectExpression, properties: string[]): RuleFix { + // todo: may not handle empty objects too well + const lastProperty = objectNode.properties[objectNode.properties.length - 1]; + const source = getSourceCode(context); + const nextToken = source.getTokenAfter(lastProperty); + + // todo: newline & indentation are hardcoded for @Component({}) + // todo: we're assuming that we need trailing commas, what if we don't? + const newPart = '\n' + properties.map(p => ` ${p},`).join('\n'); + + if (nextToken !== null && nextToken.value === ',') { + return fixer.insertTextAfter(nextToken, newPart); + } else { + return fixer.insertTextAfter(lastProperty, ',' + newPart); + } +} + +export function appendArrayElement(context: RuleContext, fixer: RuleFixer, arrayNode: TSESTree.ArrayExpression, value: string): RuleFix { + const source = getSourceCode(context); + + if (arrayNode.elements.length === 0) { + // This is the first element + const openArray = source.getTokenByRangeStart(arrayNode.range[0]); + + if (openArray == null) { + throw new Error('Unexpected null token for opening square bracket'); + } + + // safe to assume the list is single-line + return fixer.insertTextAfter(openArray, `${value}`); + } else { + const lastElement = arrayNode.elements[arrayNode.elements.length - 1]; + + if (lastElement == null) { + throw new Error('Unexpected null node in array'); + } + + const nextToken = source.getTokenAfter(lastElement); + + // todo: we don't know if the list is chopped or not, so we can't make any assumptions -- may produce output that will be flagged by other rules on the next run! + // todo: we're assuming that we need trailing commas, what if we don't? + if (nextToken !== null && nextToken.value === ',') { + return fixer.insertTextAfter(nextToken, ` ${value},`); + } else { + return fixer.insertTextAfter(lastElement, `, ${value},`); + } + } + +} + +export function isLast(elementNode: TSESTree.Node): boolean { + if (!elementNode.parent) { + return false; + } + + let siblingNodes: (TSESTree.Node | null)[] = [null]; + if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) { + siblingNodes = elementNode.parent.elements; + } else if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) { + siblingNodes = elementNode.parent.specifiers; + } + + return elementNode === siblingNodes[siblingNodes.length - 1]; +} + +export function removeWithCommas(context: RuleContext, fixer: RuleFixer, elementNode: TSESTree.Node): RuleFix[] { + const ops = []; + + const source = getSourceCode(context); + let nextToken = source.getTokenAfter(elementNode); + let prevToken = source.getTokenBefore(elementNode); + + if (nextToken !== null && prevToken !== null) { + if (nextToken.value === ',') { + nextToken = source.getTokenAfter(nextToken); + if (nextToken !== null) { + ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]])); + } + } + if (isLast(elementNode) && prevToken.value === ',') { + prevToken = source.getTokenBefore(prevToken); + if (prevToken !== null) { + ops.push(fixer.removeRange([prevToken.range[1], elementNode.range[1]])); + } + } + } else if (nextToken !== null) { + ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]])); + } + + return ops; +} + +export function replaceOrRemoveArrayIdentifier(context: RuleContext, fixer: RuleFixer, identifierNode: TSESTree.Identifier, newValue: string): RuleFix[] { + if (identifierNode.parent.type !== TSESTree.AST_NODE_TYPES.ArrayExpression) { + throw new Error('Parent node is not an array expression!'); + } + + const array = identifierNode.parent as TSESTree.ArrayExpression; + + for (const element of array.elements) { + if (element !== null && element.type === TSESTree.AST_NODE_TYPES.Identifier && element.name === newValue) { + return removeWithCommas(context, fixer, identifierNode); + } + } + + return [fixer.replaceText(identifierNode, newValue)]; +} diff --git a/lint/src/util/misc.ts b/lint/src/util/misc.ts new file mode 100644 index 0000000000..49cb60124e --- /dev/null +++ b/lint/src/util/misc.ts @@ -0,0 +1,28 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +export function match(rangeA: number[], rangeB: number[]) { + return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1]; +} + + +export function stringLiteral(value: string): string { + return `'${value}'`; +} + +/** + * Transform Windows-style paths into Unix-style paths + */ +export function toUnixStylePath(path: string): string { + // note: we're assuming that none of the directory/file names contain '\' or '/' characters. + // using these characters in paths is very bad practice in general, so this should be a safe assumption. + if (path.includes('\\')) { + return path.replace(/^[A-Z]:\\/, '/').replaceAll('\\', '/'); + } + return path; +} diff --git a/lint/src/util/structure.ts b/lint/src/util/structure.ts new file mode 100644 index 0000000000..bfbf7ec7f2 --- /dev/null +++ b/lint/src/util/structure.ts @@ -0,0 +1,57 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TSESLint } from '@typescript-eslint/utils'; +import { RuleTester } from 'eslint'; +import { EnumType } from 'typescript'; + +export type Meta = TSESLint.RuleMetaData; +export type Valid = TSESLint.ValidTestCase | RuleTester.ValidTestCase; +export type Invalid = TSESLint.InvalidTestCase | RuleTester.InvalidTestCase; + +export interface DSpaceESLintRuleInfo { + name: string; + meta: Meta, + defaultOptions: unknown[], +} + +export interface NamedTests { + plugin: string; + valid: Valid[]; + invalid: Invalid[]; +} + +export interface RuleExports { + Message: EnumType, + info: DSpaceESLintRuleInfo, + rule: TSESLint.RuleModule, + tests: NamedTests, + default: unknown, +} + +export interface PluginExports { + name: string, + language: string, + rules: Record, + index: RuleExports[], +} + +export function bundle( + name: string, + language: string, + index: RuleExports[], +): PluginExports { + return index.reduce((o: PluginExports, i: RuleExports) => { + o.rules[i.info.name] = i.rule; + return o; + }, { + name, + language, + rules: {}, + index, + }); +} diff --git a/lint/src/util/templates/index.ejs b/lint/src/util/templates/index.ejs new file mode 100644 index 0000000000..d959f29291 --- /dev/null +++ b/lint/src/util/templates/index.ejs @@ -0,0 +1,5 @@ +[DSpace ESLint plugins](../../../lint/README.md) > <%= plugin.language %> rules +_______ +<% rules.forEach(rule => { %> +- [`<%= plugin.name %>/<%= rule.name %>`](./rules/<%= rule.name %>.md)<% if (rule.meta?.docs?.description) {%>: <%= rule.meta.docs.description.split('\n')[0].trim() -%><% }-%> +<% }) %> diff --git a/lint/src/util/templates/rule.ejs b/lint/src/util/templates/rule.ejs new file mode 100644 index 0000000000..b39d193cc1 --- /dev/null +++ b/lint/src/util/templates/rule.ejs @@ -0,0 +1,48 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [<%= plugin.language %> rules](../index.md) > `<%= plugin.name %>/<%= rule.name %>` +_______ + +<%- rule.meta.docs?.description %> + +_______ + +[Source code](../../../../lint/src/rules/<%- plugin.name.replace('dspace-angular-', '') %>/<%- rule.name %>.ts) + +### Examples + +<% if (tests.valid) {%> +#### Valid code + <% tests.valid.forEach(test => { %> +##### <%= test.name !== undefined ? test.name : 'UNNAMED' %> + <% if (test.filename) { %> +Filename: `<%- test.filename %>` + <% } %> +```<%- plugin.language.toLowerCase() %> +<%- test.code.trim() %> +``` + <% }) %> +<% } %> + +<% if (tests.invalid) {%> +#### Invalid code <%= rule.meta.fixable ? ' & automatic fixes' : '' %> + <% tests.invalid.forEach(test => { %> +##### <%= test.name !== undefined ? test.name : 'UNNAMED' %> + <% if (test.filename) { %> +Filename: `<%- test.filename %>` + <% } %> +```<%- plugin.language.toLowerCase() %> +<%- test.code.trim() %> +``` +Will produce the following error(s): +``` +<% for (const error of test.errors) { -%> +<%- rule.meta.messages[error.messageId] %> +<% } -%> +``` + <% if (test.output) { %> +Result of `yarn lint --fix`: +```<%- plugin.language.toLowerCase() %> +<%- test.output.trim() %> +``` + <% } %> + <% }) %> +<% } %> diff --git a/lint/src/util/theme-support.ts b/lint/src/util/theme-support.ts new file mode 100644 index 0000000000..64644145fa --- /dev/null +++ b/lint/src/util/theme-support.ts @@ -0,0 +1,265 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TSESTree } from '@typescript-eslint/utils'; +import { readFileSync } from 'fs'; +import { basename } from 'path'; +import ts, { Identifier } from 'typescript'; + +import { + getComponentClassName, + isPartOfViewChild, +} from './angular'; +import { + isPartOfClassDeclaration, + isPartOfTypeExpression, +} from './typescript'; + +/** + * Couples a themeable Component to its ThemedComponent wrapper + */ +export interface ThemeableComponentRegistryEntry { + basePath: string; + baseFileName: string, + baseClass: string; + + wrapperPath: string; + wrapperFileName: string, + wrapperClass: string; +} + +function isAngularComponentDecorator(node: ts.Node) { + if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) { + const decorator = node as ts.Decorator; + + if (decorator.expression.kind === ts.SyntaxKind.CallExpression) { + const method = decorator.expression as ts.CallExpression; + + if (method.expression.kind === ts.SyntaxKind.Identifier) { + return (method.expression as Identifier).text === 'Component'; + } + } + } + + return false; +} + +function findImportDeclaration(source: ts.SourceFile, identifierName: string): ts.ImportDeclaration | undefined { + return ts.forEachChild(source, (topNode: ts.Node) => { + if (topNode.kind === ts.SyntaxKind.ImportDeclaration) { + const importDeclaration = topNode as ts.ImportDeclaration; + + if (importDeclaration.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) { + const namedImports = importDeclaration.importClause?.namedBindings as ts.NamedImports; + + for (const element of namedImports.elements) { + if (element.name.text === identifierName) { + return importDeclaration; + } + } + } + } + + return undefined; + }); +} + +/** + * Listing of all themeable Components + */ +class ThemeableComponentRegistry { + public readonly entries: Set; + public readonly byBaseClass: Map; + public readonly byWrapperClass: Map; + public readonly byBasePath: Map; + public readonly byWrapperPath: Map; + + constructor() { + this.entries = new Set(); + this.byBaseClass = new Map(); + this.byWrapperClass = new Map(); + this.byBasePath = new Map(); + this.byWrapperPath = new Map(); + } + + public initialize(prefix = '') { + if (this.entries.size > 0) { + return; + } + + function registerWrapper(path: string) { + const source = getSource(path); + + function traverse(node: ts.Node) { + if (node.parent !== undefined && isAngularComponentDecorator(node)) { + const classNode = node.parent as ts.ClassDeclaration; + + if (classNode.name === undefined || classNode.heritageClauses === undefined) { + return; + } + + const wrapperClass = classNode.name?.escapedText as string; + + for (const heritageClause of classNode.heritageClauses) { + for (const type of heritageClause.types) { + if ((type as any).expression.escapedText === 'ThemedComponent') { + if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) { + continue; + } + + const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode; + const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText; + + if (baseClass === undefined) { + continue; + } + + const importDeclaration = findImportDeclaration(source, baseClass); + + if (importDeclaration === undefined) { + continue; + } + + const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path); + + themeableComponents.add({ + baseClass, + basePath: basePath.replace(new RegExp(`^${prefix}`), ''), + baseFileName: basename(basePath).replace(/\.ts$/, ''), + wrapperClass, + wrapperPath: path.replace(new RegExp(`^${prefix}`), ''), + wrapperFileName: basename(path).replace(/\.ts$/, ''), + }); + } + } + } + + return; + } else { + ts.forEachChild(node, traverse); + } + } + + traverse(source); + } + + const glob = require('glob'); + + // note: this outputs Unix-style paths on Windows + const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found; + + for (const wrapper of wrappers) { + registerWrapper(wrapper); + } + } + + private add(entry: ThemeableComponentRegistryEntry) { + this.entries.add(entry); + this.byBaseClass.set(entry.baseClass, entry); + this.byWrapperClass.set(entry.wrapperClass, entry); + this.byBasePath.set(entry.basePath, entry); + this.byWrapperPath.set(entry.wrapperPath, entry); + } +} + +export const themeableComponents = new ThemeableComponentRegistry(); + +/** + * Construct the AST of a TypeScript source file + * @param file + */ +function getSource(file: string): ts.SourceFile { + return ts.createSourceFile( + file, + readFileSync(file).toString(), + ts.ScriptTarget.ES2020, // todo: actually use tsconfig.json? + /*setParentNodes */ true, + ); +} + +/** + * Resolve a possibly relative local path into an absolute path starting from the root directory of the project + */ +function resolveLocalPath(path: string, relativeTo: string) { + if (path.startsWith('src/')) { + return path; + } else if (path.startsWith('./')) { + const parts = relativeTo.split('/'); + return [ + ...parts.slice(0, parts.length - 1), + path.replace(/^.\//, ''), + ].join('/') + '.ts'; + } else { + throw new Error(`Unsupported local path: ${path}`); + } +} + +export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return false; + } + + if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return false; + } + + return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent'; +} + +export function getBaseComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined { + const wrapperClass = getComponentClassName(decoratorNode); + + if (wrapperClass === undefined) { + return; + } + + themeableComponents.initialize(); + const entry = themeableComponents.byWrapperClass.get(wrapperClass); + + if (entry === undefined) { + return undefined; + } + + return entry.baseClass; +} + +export function isThemeableComponent(className: string): boolean { + themeableComponents.initialize(); + return themeableComponents.byBaseClass.has(className); +} + +export function inThemedComponentOverrideFile(filename: string): boolean { + const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/); + + if (!match) { + return false; + } + themeableComponents.initialize(); + // todo: this is fragile! + return themeableComponents.byBasePath.has(`src/${match[1]}`); +} + +export function allThemeableComponents(): ThemeableComponentRegistryEntry[] { + themeableComponents.initialize(); + return [...themeableComponents.entries]; +} + +export function getThemeableComponentByBaseClass(baseClass: string): ThemeableComponentRegistryEntry | undefined { + themeableComponents.initialize(); + return themeableComponents.byBaseClass.get(baseClass); +} + +export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) { + return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode); +} + +export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-'; + +export function fixSelectors(text: string): string { + return text.replaceAll(/ds-(base|themed)-/g, 'ds-'); +} diff --git a/lint/src/util/typescript.ts b/lint/src/util/typescript.ts new file mode 100644 index 0000000000..3fecad270e --- /dev/null +++ b/lint/src/util/typescript.ts @@ -0,0 +1,154 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { + match, + toUnixStylePath, +} from './misc'; + +export type AnyRuleContext = TSESLint.RuleContext; + +/** + * Return the current filename based on the ESLint rule context as a Unix-style path. + * This is easier for regex and comparisons to glob paths. + */ +export function getFilename(context: AnyRuleContext): string { + // TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?) + // eslint-disable-next-line deprecation/deprecation + return toUnixStylePath(context.getFilename()); +} + +export function getSourceCode(context: AnyRuleContext): TSESLint.SourceCode { + // TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?) + // eslint-disable-next-line deprecation/deprecation + return context.getSourceCode(); +} + +export function getObjectPropertyNodeByName(objectNode: TSESTree.ObjectExpression, propertyName: string): TSESTree.Node | undefined { + for (const propertyNode of objectNode.properties) { + if ( + propertyNode.type === TSESTree.AST_NODE_TYPES.Property + && ( + ( + propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Identifier + && propertyNode.key?.name === propertyName + ) || ( + propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Literal + && propertyNode.key?.value === propertyName + ) + ) + ) { + return propertyNode.value; + } + } + return undefined; +} + +export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifier): TSESTree.Identifier[] { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === localNode.name && !match(token.range, localNode.range)) { + const node = source.getNodeByRangeIndex(token.range[0]); + // todo: in some cases, the resulting node can actually be the whole program (!) + if (node !== null) { + usages.push(node as TSESTree.Identifier); + } + } + } + + return usages; +} + +export function findUsagesByName(context: AnyRuleContext, identifier: string): TSESTree.Identifier[] { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === identifier) { + const node = source.getNodeByRangeIndex(token.range[0]); + // todo: in some cases, the resulting node can actually be the whole program (!) + if (node !== null) { + usages.push(node as TSESTree.Identifier); + } + } + } + + return usages; +} + +export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean { + return node.parent?.type?.valueOf().startsWith('TSType'); +} + +export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean { + return node.parent?.type === TSESTree.AST_NODE_TYPES.ClassDeclaration; +} + +function fromSrc(path: string): string { + const m = path.match(/^.*(src\/.+)(\.(ts|json|js)?)$/); + + if (m) { + return m[1]; + } else { + throw new Error(`Can't infer project-absolute TS/resource path from: ${path}`); + } +} + + +export function relativePath(thisFile: string, importFile: string): string { + const fromParts = fromSrc(thisFile).split('/'); + const toParts = fromSrc(importFile).split('/'); + + let lastCommon = 0; + for (let i = 0; i < fromParts.length - 1; i++) { + if (fromParts[i] === toParts[i]) { + lastCommon++; + } else { + break; + } + } + + const path = toParts.slice(lastCommon, toParts.length).join('/'); + const backtrack = fromParts.length - lastCommon - 1; + + let prefix: string; + if (backtrack > 0) { + prefix = '../'.repeat(backtrack); + } else { + prefix = './'; + } + + return prefix + path; +} + + +export function findImportSpecifier(context: AnyRuleContext, identifier: string): TSESTree.ImportSpecifier | undefined { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === identifier) { + const node = source.getNodeByRangeIndex(token.range[0]); + // todo: in some cases, the resulting node can actually be the whole program (!) + if (node && node.parent && node.parent.type === TSESTree.AST_NODE_TYPES.ImportSpecifier) { + return node.parent; + } + } + } + + return undefined; +} diff --git a/lint/test/fixture/README.md b/lint/test/fixture/README.md new file mode 100644 index 0000000000..b19ae11b55 --- /dev/null +++ b/lint/test/fixture/README.md @@ -0,0 +1,9 @@ +# ESLint testing fixtures + +The files in this directory are used for the ESLint testing environment +- Some rules rely on registries that must be built up _before_ the rule is run + - In order to test these registries, the fixture sources contain a few dummy components +- The TypeScript ESLint test runner requires at least one dummy file to exist to run any tests + - By default, [`test.ts`](./src/test.ts) is used. Note that this file is empty; it's only there for the TypeScript configuration, the actual content is injected from the `code` property in the tests. + - To test rules that make assertions based on the path of the file, you'll need to include the `filename` property in the test configuration. Note that it must point to an existing file too! + - The `filename` must be provided as `fixture('src/something.ts')` \ No newline at end of file diff --git a/lint/test/fixture/index.ts b/lint/test/fixture/index.ts new file mode 100644 index 0000000000..1d4f33f7e2 --- /dev/null +++ b/lint/test/fixture/index.ts @@ -0,0 +1,13 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +export const FIXTURE = 'lint/test/fixture/'; + +export function fixture(path: string): string { + return FIXTURE + path; +} diff --git a/lint/test/fixture/src/app/test/test-routing.module.ts b/lint/test/fixture/src/app/test/test-routing.module.ts new file mode 100644 index 0000000000..1ccbccc599 --- /dev/null +++ b/lint/test/fixture/src/app/test/test-routing.module.ts @@ -0,0 +1,14 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { ThemedTestThemeableComponent } from './themed-test-themeable.component'; + +export const ROUTES = [ + { + component: ThemedTestThemeableComponent, + }, +]; diff --git a/lint/test/fixture/src/app/test/test-themeable.component.ts b/lint/test/fixture/src/app/test/test-themeable.component.ts new file mode 100644 index 0000000000..b445040539 --- /dev/null +++ b/lint/test/fixture/src/app/test/test-themeable.component.ts @@ -0,0 +1,16 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-base-test-themeable', + template: '', + standalone: true, +}) +export class TestThemeableComponent { +} diff --git a/lint/test/fixture/src/app/test/test.component.cy.ts b/lint/test/fixture/src/app/test/test.component.cy.ts new file mode 100644 index 0000000000..2300ac4a56 --- /dev/null +++ b/lint/test/fixture/src/app/test/test.component.cy.ts @@ -0,0 +1,8 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + diff --git a/lint/test/fixture/src/app/test/test.component.spec.ts b/lint/test/fixture/src/app/test/test.component.spec.ts new file mode 100644 index 0000000000..2300ac4a56 --- /dev/null +++ b/lint/test/fixture/src/app/test/test.component.spec.ts @@ -0,0 +1,8 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + diff --git a/lint/test/fixture/src/app/test/test.component.ts b/lint/test/fixture/src/app/test/test.component.ts new file mode 100644 index 0000000000..c01f104c98 --- /dev/null +++ b/lint/test/fixture/src/app/test/test.component.ts @@ -0,0 +1,15 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-test', + template: '', +}) +export class TestComponent { +} diff --git a/lint/test/fixture/src/app/test/test.module.ts b/lint/test/fixture/src/app/test/test.module.ts new file mode 100644 index 0000000000..a37396ef45 --- /dev/null +++ b/lint/test/fixture/src/app/test/test.module.ts @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +// @ts-ignore +import { NgModule } from '@angular/core'; + +import { TestComponent } from './test.component'; +import { TestThemeableComponent } from './test-themeable.component'; +import { ThemedTestThemeableComponent } from './themed-test-themeable.component'; + +@NgModule({ + declarations: [ + TestComponent, + TestThemeableComponent, + ThemedTestThemeableComponent, + ], +}) +export class TestModule { + +} diff --git a/lint/test/fixture/src/app/test/themed-test-themeable.component.ts b/lint/test/fixture/src/app/test/themed-test-themeable.component.ts new file mode 100644 index 0000000000..2697a8c598 --- /dev/null +++ b/lint/test/fixture/src/app/test/themed-test-themeable.component.ts @@ -0,0 +1,31 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +import { ThemedComponent } from '../../../../../../src/app/shared/theme-support/themed.component'; +import { TestThemeableComponent } from './test-themeable.component'; + +@Component({ + selector: 'ds-test-themeable', + template: '', + standalone: true, + imports: [TestThemeableComponent], +}) +export class ThemedTestThemeableComponent extends ThemedComponent { + protected getComponentName(): string { + return ''; + } + + protected importThemedComponent(themeName: string): Promise { + return Promise.resolve(undefined); + } + + protected importUnthemedComponent(): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/app/collection-page/sections/recently-added/collection-recently-added.component.scss b/lint/test/fixture/src/test.ts similarity index 100% rename from src/app/collection-page/sections/recently-added/collection-recently-added.component.scss rename to lint/test/fixture/src/test.ts diff --git a/lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts b/lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts new file mode 100644 index 0000000000..f72161b2bf --- /dev/null +++ b/lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts @@ -0,0 +1,16 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-themed-test-themeable', + template: '', +}) +export class OtherThemeableComponent { + +} diff --git a/lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts b/lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts new file mode 100644 index 0000000000..d2b02ca9f1 --- /dev/null +++ b/lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts @@ -0,0 +1,18 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +import { TestThemeableComponent as BaseComponent } from '../../../../app/test/test-themeable.component'; + +@Component({ + selector: 'ds-themed-test-themeable', + template: '', +}) +export class TestThemeableComponent extends BaseComponent { + +} diff --git a/lint/test/fixture/src/themes/test/test.module.ts b/lint/test/fixture/src/themes/test/test.module.ts new file mode 100644 index 0000000000..ff6ec3b2c0 --- /dev/null +++ b/lint/test/fixture/src/themes/test/test.module.ts @@ -0,0 +1,22 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +// @ts-ignore +import { NgModule } from '@angular/core'; + +import { OtherThemeableComponent } from './app/test/other-themeable.component'; +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +@NgModule({ + declarations: [ + TestThemeableComponent, + OtherThemeableComponent, + ], +}) +export class TestModule { + +} diff --git a/lint/test/fixture/tsconfig.json b/lint/test/fixture/tsconfig.json new file mode 100644 index 0000000000..0fd1141ae0 --- /dev/null +++ b/lint/test/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*.ts" + ], + "exclude": [] +} diff --git a/lint/test/helpers.js b/lint/test/helpers.js new file mode 100644 index 0000000000..bd648d007f --- /dev/null +++ b/lint/test/helpers.js @@ -0,0 +1,13 @@ +const SpecReporter = require('jasmine-spec-reporter').SpecReporter; +const StacktraceOption = require('jasmine-spec-reporter').StacktraceOption; + +jasmine.getEnv().clearReporters(); // Clear default console reporter for those instead +jasmine.getEnv().addReporter(new SpecReporter({ + spec: { + displayErrorMessages: false, + }, + summary: { + displayFailed: true, + displayStacktrace: StacktraceOption.PRETTY, + }, +})); diff --git a/lint/test/rules.spec.ts b/lint/test/rules.spec.ts new file mode 100644 index 0000000000..11c9bec46c --- /dev/null +++ b/lint/test/rules.spec.ts @@ -0,0 +1,26 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { default as htmlPlugin } from '../src/rules/html'; +import { default as tsPlugin } from '../src/rules/ts'; +import { + htmlRuleTester, + tsRuleTester, +} from './testing'; + +describe('TypeScript rules', () => { + for (const { info, rule, tests } of tsPlugin.index) { + tsRuleTester.run(info.name, rule, tests as any); + } +}); + +describe('HTML rules', () => { + for (const { info, rule, tests } of htmlPlugin.index) { + htmlRuleTester.run(info.name, rule, tests); + } +}); diff --git a/lint/test/structure.spec.ts b/lint/test/structure.spec.ts new file mode 100644 index 0000000000..24e69e42d9 --- /dev/null +++ b/lint/test/structure.spec.ts @@ -0,0 +1,76 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { default as html } from '../src/rules/html'; +import { default as ts } from '../src/rules/ts'; + +describe('plugin structure', () => { + for (const pluginExports of [ts, html]) { + const pluginName = pluginExports.name ?? 'UNNAMED PLUGIN'; + + describe(pluginName, () => { + it('should have a name', () => { + expect(pluginExports.name).toBeTruthy(); + }); + + it('should have rules', () => { + expect(pluginExports.index).toBeTruthy(); + expect(pluginExports.rules).toBeTruthy(); + expect(pluginExports.index.length).toBeGreaterThan(0); + }); + + for (const ruleExports of pluginExports.index) { + const ruleName = ruleExports.info.name ?? 'UNNAMED RULE'; + + describe(ruleName, () => { + it('should have a name', () => { + expect(ruleExports.info.name).toBeTruthy(); + }); + + it('should be included under the right name in the plugin', () => { + expect(pluginExports.rules[ruleExports.info.name]).toBe(ruleExports.rule); + }); + + it('should contain metadata', () => { + expect(ruleExports.info).toBeTruthy(); + expect(ruleExports.info.name).toBeTruthy(); + expect(ruleExports.info.meta).toBeTruthy(); + expect(ruleExports.info.defaultOptions).toBeTruthy(); + }); + + it('should contain messages', () => { + expect(ruleExports.Message).toBeTruthy(); + expect(ruleExports.info.meta.messages).toBeTruthy(); + }); + + describe('messages', () => { + for (const member of Object.keys(ruleExports.Message)) { + describe(member, () => { + const id = (ruleExports.Message as any)[member]; + + it('should have a valid ID', () => { + expect(id).toBeTruthy(); + }); + + it('should have valid metadata', () => { + expect(ruleExports.info.meta.messages[id]).toBeTruthy(); + }); + }); + } + }); + + it('should contain tests', () => { + expect(ruleExports.tests).toBeTruthy(); + expect(ruleExports.tests.valid.length).toBeGreaterThan(0); + expect(ruleExports.tests.invalid.length).toBeGreaterThan(0); + }); + }); + } + }); + } +}); diff --git a/lint/test/testing.ts b/lint/test/testing.ts new file mode 100644 index 0000000000..cfa54c5b85 --- /dev/null +++ b/lint/test/testing.ts @@ -0,0 +1,53 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester'; +import { RuleTester } from 'eslint'; + +import { themeableComponents } from '../src/util/theme-support'; +import { + FIXTURE, + fixture, +} from './fixture'; + + +// Register themed components from test fixture +themeableComponents.initialize(FIXTURE); + +TypeScriptRuleTester.itOnly = fit; +TypeScriptRuleTester.itSkip = xit; + +export const tsRuleTester = new TypeScriptRuleTester({ + parser: '@typescript-eslint/parser', + defaultFilenames: { + ts: fixture('src/test.ts'), + tsx: 'n/a', + }, + parserOptions: { + project: fixture('tsconfig.json'), + }, +}); + +class HtmlRuleTester extends RuleTester { + run(name: string, rule: any, tests: { valid: any[], invalid: any[] }) { + super.run(name, rule, { + valid: tests.valid.map((test) => ({ + filename: fixture('test.html'), + ...test, + })), + invalid: tests.invalid.map((test) => ({ + filename: fixture('test.html'), + ...test, + })), + }); + } +} + +export const htmlRuleTester = new HtmlRuleTester({ + parser: require.resolve('@angular-eslint/template-parser'), +}); diff --git a/lint/test/theme-support.spec.ts b/lint/test/theme-support.spec.ts new file mode 100644 index 0000000000..2edf9594b6 --- /dev/null +++ b/lint/test/theme-support.spec.ts @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { themeableComponents } from '../src/util/theme-support'; + +describe('theme-support', () => { + describe('themeable component registry', () => { + it('should contain all themeable components from the fixture', () => { + expect(themeableComponents.entries.size).toBe(1); + expect(themeableComponents.byBasePath.size).toBe(1); + expect(themeableComponents.byWrapperPath.size).toBe(1); + expect(themeableComponents.byBaseClass.size).toBe(1); + + expect(themeableComponents.byBaseClass.get('TestThemeableComponent')).toBeTruthy(); + expect(themeableComponents.byBasePath.get('src/app/test/test-themeable.component.ts')).toBeTruthy(); + expect(themeableComponents.byWrapperPath.get('src/app/test/themed-test-themeable.component.ts')).toBeTruthy(); + }); + }); +}); diff --git a/lint/tsconfig.json b/lint/tsconfig.json new file mode 100644 index 0000000000..d3537a7376 --- /dev/null +++ b/lint/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": [ + "es2021" + ], + "module": "nodenext", + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "skipLibCheck": true, + "strict": true, + "outDir": "./dist", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "types": [ + "jasmine", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "dist", + "test/fixture" + ] +} diff --git a/package.json b/package.json index e5347742c8..e3f8ce696e 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,16 @@ "build:stats": "ng build --stats-json", "build:prod": "cross-env NODE_ENV=production yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", + "build:lint": "rimraf 'lint/dist/**/*.js' 'lint/dist/**/*.js.map' && tsc -b lint/tsconfig.json", "test": "ng test --source-map=true --watch=false --configuration test", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", - "lint": "ng lint", - "lint-fix": "ng lint --fix=true", + "test:lint": "yarn build:lint && yarn test:lint:nobuild", + "test:lint:nobuild": "jasmine --config=lint/jasmine.json", + "lint": "yarn build:lint && yarn lint:nobuild", + "lint:nobuild": "ng lint", + "lint-fix": "yarn build:lint && ng lint --fix=true", + "docs:lint": "ts-node --project ./lint/tsconfig.json ./lint/generate-docs.ts", "e2e": "cross-env NODE_ENV=production ng e2e", "clean:dev:config": "rimraf src/assets/config.json", "clean:coverage": "rimraf coverage", @@ -40,7 +45,8 @@ "cypress:run": "cypress run", "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts", "base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts", - "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./" + "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./", + "postinstall": "yarn build:lint || echo 'Skipped DSpace ESLint plugins.'" }, "browser": { "fs": false, @@ -55,28 +61,28 @@ "ts-node": "10.2.1" }, "dependencies": { - "@angular/animations": "^15.2.8", - "@angular/cdk": "^15.2.8", - "@angular/common": "^15.2.8", - "@angular/compiler": "^15.2.8", - "@angular/core": "^15.2.8", - "@angular/forms": "^15.2.8", - "@angular/localize": "15.2.8", - "@angular/platform-browser": "^15.2.8", - "@angular/platform-browser-dynamic": "^15.2.8", - "@angular/platform-server": "^15.2.8", - "@angular/router": "^15.2.8", + "@angular/animations": "^17.3.4", + "@angular/cdk": "^17.3.4", + "@angular/common": "^17.3.4", + "@angular/compiler": "^17.3.4", + "@angular/core": "^17.3.4", + "@angular/forms": "^17.3.4", + "@angular/localize": "17.3.4", + "@angular/platform-browser": "^17.3.4", + "@angular/platform-browser-dynamic": "^17.3.4", + "@angular/platform-server": "^17.3.4", + "@angular/router": "^17.3.4", + "@angular/ssr": "^17.3.0", "@babel/runtime": "7.21.0", "@kolkov/ngx-gallery": "^2.0.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.11.3", "@ng-bootstrap/ng-bootstrap": "^11.0.0", - "@ng-dynamic-forms/core": "^15.0.0", - "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0", - "@ngrx/effects": "^15.4.0", - "@ngrx/router-store": "^15.4.0", - "@ngrx/store": "^15.4.0", - "@nguniversal/express-engine": "^15.2.1", + "@ng-dynamic-forms/core": "^16.0.0", + "@ng-dynamic-forms/ui-ng-bootstrap": "^16.0.0", + "@ngrx/effects": "^17.1.1", + "@ngrx/router-store": "^17.1.1", + "@ngrx/store": "^17.1.1", "@ngx-translate/core": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@types/grecaptcha": "^3.0.4", @@ -93,8 +99,8 @@ "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", - "ejs": "^3.1.9", - "express": "^4.18.2", + "ejs": "^3.1.10", + "express": "^4.19.2", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", @@ -110,17 +116,15 @@ "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", - "markdown-it-mathjax3": "^4.3.2", "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", "morgan": "^1.10.0", "ng-mocks": "^14.10.0", - "ng2-file-upload": "1.4.0", + "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", - "ngx-infinite-scroll": "^15.0.0", + "ngx-infinite-scroll": "^16.0.0", "ngx-pagination": "6.0.3", - "ngx-sortablejs": "^11.1.0", "ngx-ui-switch": "^14.1.0", "nouislider": "^15.7.1", "pem": "1.14.7", @@ -128,28 +132,28 @@ "react-copy-to-clipboard": "^5.1.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", - "sanitize-html": "^2.10.0", + "sanitize-html": "^2.12.1", "sortablejs": "1.15.0", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "~0.11.5" + "zone.js": "~0.14.4" }, "devDependencies": { - "@angular-builders/custom-webpack": "~15.0.0", - "@angular-devkit/build-angular": "^15.2.6", - "@angular-eslint/builder": "15.2.1", - "@angular-eslint/eslint-plugin": "15.2.1", - "@angular-eslint/eslint-plugin-template": "15.2.1", - "@angular-eslint/schematics": "15.2.1", - "@angular-eslint/template-parser": "15.2.1", - "@angular/cli": "^15.2.6", - "@angular/compiler-cli": "^15.2.8", - "@angular/language-service": "^15.2.8", + "@angular-builders/custom-webpack": "~17.0.1", + "@angular-devkit/build-angular": "^17.3.0", + "@angular-eslint/builder": "17.2.1", + "@angular-eslint/bundled-angular-compiler": "17.2.1", + "@angular-eslint/eslint-plugin": "17.2.1", + "@angular-eslint/eslint-plugin-template": "17.2.1", + "@angular-eslint/schematics": "17.2.1", + "@angular-eslint/template-parser": "17.2.1", + "@angular/cli": "^17.3.0", + "@angular/compiler-cli": "^17.3.4", + "@angular/language-service": "^17.3.4", "@cypress/schematic": "^1.5.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@ngrx/store-devtools": "^15.4.0", - "@ngtools/webpack": "^15.2.6", - "@nguniversal/builders": "^15.2.1", + "@ngrx/store-devtools": "^17.1.1", + "@ngtools/webpack": "^16.2.12", "@types/deep-freeze": "0.1.2", "@types/ejs": "^3.1.2", "@types/express": "^4.17.17", @@ -158,9 +162,12 @@ "@types/lodash": "^4.14.194", "@types/node": "^14.14.9", "@types/sanitize-html": "^2.9.0", - "@typescript-eslint/eslint-plugin": "^5.59.1", - "@typescript-eslint/parser": "^5.59.1", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@typescript-eslint/rule-tester": "^7.2.0", + "@typescript-eslint/utils": "^7.2.0", "axe-core": "^4.7.2", + "browser-sync": "^3.0.0", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", @@ -169,12 +176,18 @@ "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", + "eslint-plugin-dspace-angular-html": "link:./lint/dist/src/rules/html", + "eslint-plugin-dspace-angular-ts": "link:./lint/dist/src/rules/ts", "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^39.6.4", + "eslint-plugin-import-newlines": "^1.3.1", + "eslint-plugin-jsdoc": "^45.0.0", "eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-lodash": "^7.4.0", + "eslint-plugin-rxjs": "^5.0.3", + "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-unused-imports": "^2.0.0", "express-static-gzip": "^2.1.7", + "jasmine": "^3.8.0", "jasmine-core": "^3.8.0", "jasmine-marbles": "0.9.2", "karma": "^6.4.2", @@ -183,7 +196,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ngx-mask": "^13.1.7", + "ngx-mask": "14.2.4", "nodemon": "^2.0.22", "postcss": "^8.4", "postcss-apply": "0.12.0", @@ -199,7 +212,7 @@ "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", - "typescript": "~4.8.4", + "typescript": "~5.3.3", "webpack": "5.76.1", "webpack-bundle-analyzer": "^4.8.0", "webpack-cli": "^4.2.0", diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index 96ba0d4010..170266b6a2 100644 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -275,7 +275,9 @@ function readFileIfExists(pathToFile) { try { return fs.readFileSync(pathToFile, 'utf8'); } catch (e) { - console.error('Error:', e.stack); + if (e instanceof Error) { + console.error('Error:', e.stack); + } } } return null; diff --git a/server.ts b/server.ts index da085f372f..22f3423287 100644 --- a/server.ts +++ b/server.ts @@ -17,7 +17,6 @@ import 'zone.js/node'; import 'reflect-metadata'; -import 'rxjs'; /* eslint-disable import/no-namespace */ import * as morgan from 'morgan'; @@ -39,23 +38,26 @@ import { join } from 'path'; import { enableProdMode } from '@angular/core'; -import { ngExpressEngine } from '@nguniversal/express-engine'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasNoValue, hasValue } from './src/app/shared/empty.util'; - +import { hasValue } from './src/app/shared/empty.util'; import { UIServerConfig } from './src/config/ui-server-config.interface'; - -import { ServerAppModule } from './src/main.server'; - +import bootstrap from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; -import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; +import { + APP_CONFIG, + AppConfig, +} from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; import { logStartupMessage } from './startup-message'; import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model'; - +import { CommonEngine } from '@angular/ssr'; +import { APP_BASE_HREF } from '@angular/common'; +import { + REQUEST, + RESPONSE, +} from './src/express.tokens'; /* * Set path for the browser application's dist folder @@ -127,27 +129,6 @@ export function app() { */ server.use(json()); - // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) - server.engine('html', (_, options, callback) => - ngExpressEngine({ - bootstrap: ServerAppModule, - providers: [ - { - provide: REQUEST, - useValue: (options as any).req, - }, - { - provide: RESPONSE, - useValue: (options as any).req.res, - }, - { - provide: APP_CONFIG, - useValue: environment - } - ] - })(_, (options as any), callback) - ); - server.engine('ejs', ejs.renderFile); /* @@ -162,7 +143,7 @@ export function app() { server.get('/robots.txt', (req, res) => { res.setHeader('content-type', 'text/plain'); res.render('assets/robots.txt.ejs', { - 'origin': req.protocol + '://' + req.headers.host + 'origin': req.protocol + '://' + req.headers.host, }); }); @@ -177,7 +158,7 @@ export function app() { router.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), - changeOrigin: true + changeOrigin: true, })); /** @@ -186,7 +167,7 @@ export function app() { router.use('/signposting**', createProxyMiddleware({ target: `${environment.rest.baseUrl}`, pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), - changeOrigin: true + changeOrigin: true, })); /** @@ -197,7 +178,7 @@ export function app() { const RateLimit = require('express-rate-limit'); const limiter = new RateLimit({ windowMs: (environment.ui as UIServerConfig).rateLimiter.windowMs, - max: (environment.ui as UIServerConfig).rateLimiter.max + max: (environment.ui as UIServerConfig).rateLimiter.max, }); server.use(limiter); } @@ -236,10 +217,10 @@ export function app() { /* * The callback function to serve server side angular */ -function ngApp(req, res) { - if (environment.universal.preboot) { +function ngApp(req, res, next) { + if (environment.ssr.enabled) { // Render the page to user via SSR (server side rendering) - serverSideRender(req, res); + serverSideRender(req, res, next); } else { // If preboot is disabled, just serve the client console.log('Universal off, serving for direct client-side rendering (CSR)'); @@ -252,45 +233,66 @@ function ngApp(req, res) { * returned to the user. * @param req current request * @param res current response + * @param next the next function * @param sendToUser if true (default), send the rendered content to the user. * If false, then only save this rendered content to the in-memory cache (to refresh cache). */ -function serverSideRender(req, res, sendToUser: boolean = true) { +function serverSideRender(req, res, next, sendToUser: boolean = true) { + const { protocol, originalUrl, baseUrl, headers } = req; + const commonEngine = new CommonEngine({ enablePerformanceProfiler: environment.ssr.enablePerformanceProfiler }); // Render the page via SSR (server side rendering) - res.render(indexHtml, { - req, - res, - preboot: environment.universal.preboot, - async: environment.universal.async, - time: environment.universal.time, - baseUrl: environment.ui.nameSpace, - originUrl: environment.ui.baseUrl, - requestUrl: req.originalUrl, - }, (err, data) => { - if (hasNoValue(err) && hasValue(data)) { - // save server side rendered page to cache (if any are enabled) - saveToCache(req, data); - if (sendToUser) { - res.locals.ssr = true; // mark response as SSR (enables text compression) - // send rendered page to user - res.send(data); + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + inlineCriticalCss: environment.ssr.inlineCriticalCss, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: DIST_FOLDER, + providers: [ + { provide: APP_BASE_HREF, useValue: baseUrl }, + { + provide: REQUEST, + useValue: req, + }, + { + provide: RESPONSE, + useValue: res, + }, + { + provide: APP_CONFIG, + useValue: environment, + }, + ], + }) + .then((html) => { + if (hasValue(html)) { + // save server side rendered page to cache (if any are enabled) + saveToCache(req, html); + if (sendToUser) { + res.locals.ssr = true; // mark response as SSR (enables text compression) + // send rendered page to user + res.send(html); + } } - } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { - // When this error occurs we can't fall back to CSR because the response has already been - // sent. These errors occur for various reasons in universal, not all of which are in our - // control to solve. - console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client'); - } else { - console.warn('Error in server-side rendering (SSR)'); - if (hasValue(err)) { - console.warn('Error details : ', err); + }) + .catch((err) => { + if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { + // When this error occurs we can't fall back to CSR because the response has already been + // sent. These errors occur for various reasons in universal, not all of which are in our + // control to solve. + console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client'); + } else { + console.warn('Error in server-side rendering (SSR)'); + if (hasValue(err)) { + console.warn('Error details : ', err); + } + if (sendToUser) { + console.warn('Falling back to serving direct client-side rendering (CSR).'); + clientSideRender(req, res); + } } - if (sendToUser) { - console.warn('Falling back to serving direct client-side rendering (CSR).'); - clientSideRender(req, res); - } - } - }); + next(err); + }); } /** @@ -325,7 +327,7 @@ function initCache() { botCache = new LRU( { max: environment.cache.serverSide.botCache.max, ttl: environment.cache.serverSide.botCache.timeToLive, - allowStale: environment.cache.serverSide.botCache.allowStale + allowStale: environment.cache.serverSide.botCache.allowStale, }); } @@ -337,7 +339,7 @@ function initCache() { anonymousCache = new LRU( { max: environment.cache.serverSide.anonymousCache.max, ttl: environment.cache.serverSide.anonymousCache.timeToLive, - allowStale: environment.cache.serverSide.anonymousCache.allowStale + allowStale: environment.cache.serverSide.anonymousCache.allowStale, }); } } @@ -348,7 +350,7 @@ function initCache() { function botCacheEnabled(): boolean { // Caching is only enabled if SSR is enabled AND // "max" pages to cache is greater than zero - return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0); + return environment.ssr.enabled && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0); } /** @@ -357,7 +359,7 @@ function botCacheEnabled(): boolean { function anonymousCacheEnabled(): boolean { // Caching is only enabled if SSR is enabled AND // "max" pages to cache is greater than zero - return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0); + return environment.ssr.enabled && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0); } /** @@ -370,9 +372,9 @@ function cacheCheck(req, res, next) { // If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page. if (botCacheEnabled() && isbot(req.get('user-agent'))) { - cachedCopy = checkCacheForRequest('bot', botCache, req, res); + cachedCopy = checkCacheForRequest('bot', botCache, req, res, next); } else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) { - cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res); + cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res, next); } // If cached copy exists, return it to the user. @@ -408,14 +410,15 @@ function cacheCheck(req, res, next) { * @param cache LRU cache to check * @param req current request to look for in the cache * @param res current response + * @param next the next function * @returns cached copy (if found) or undefined (if not found) */ -function checkCacheForRequest(cacheName: string, cache: LRU, req, res): any { +function checkCacheForRequest(cacheName: string, cache: LRU, req, res, next): any { // Get the cache key for this request const key = getCacheKey(req); // Check if this page is in our cache - let cachedCopy = cache.get(key); + const cachedCopy = cache.get(key); if (cachedCopy) { if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); } @@ -426,7 +429,7 @@ function checkCacheForRequest(cacheName: string, cache: LRU, req, r // Update cached copy by rerendering server-side // NOTE: In this scenario the currently cached copy will be returned to the current user. // This re-render is peformed behind the scenes to update cached copy for next user. - serverSideRender(req, res, false); + serverSideRender(req, res, next, false); } } else { if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); } @@ -529,20 +532,20 @@ function serverStarted() { function createHttpsServer(keys) { const listener = createServer({ key: keys.serviceKey, - cert: keys.certificate - }, app).listen(environment.ui.port, environment.ui.host, () => { + cert: keys.certificate, + }, app()).listen(environment.ui.port, environment.ui.host, () => { serverStarted(); }); // Graceful shutdown when signalled - const terminator = createHttpTerminator({server: listener}); + const terminator = createHttpTerminator({ server: listener }); process.on('SIGINT', () => { - void (async ()=> { - console.debug('Closing HTTPS server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); - console.debug('HTTPS server closed'); - })(); - }); + void (async ()=> { + console.debug('Closing HTTPS server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTPS server closed'); + })(); + }); } /** @@ -559,14 +562,14 @@ function run() { }); // Graceful shutdown when signalled - const terminator = createHttpTerminator({server: listener}); + const terminator = createHttpTerminator({ server: listener }); process.on('SIGINT', () => { - void (async () => { - console.debug('Closing HTTP server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); - console.debug('HTTP server closed.');return undefined; - })(); - }); + void (async () => { + console.debug('Closing HTTP server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTP server closed.');return undefined; + })(); + }); } function start() { @@ -597,7 +600,7 @@ function start() { if (serviceKey && certificate) { createHttpsServer({ serviceKey: serviceKey, - certificate: certificate + certificate: certificate, }); } else { console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); @@ -606,7 +609,7 @@ function start() { createCertificate({ days: 1, - selfSigned: true + selfSigned: true, }, (error, keys) => { createHttpsServer(keys); }); @@ -627,7 +630,7 @@ function healthCheck(req, res) { }) .catch((error) => { res.status(error.response.status).send({ - error: error.message + error: error.message, }); }); } diff --git a/src/app/access-control/access-control-routes.ts b/src/app/access-control/access-control-routes.ts new file mode 100644 index 0000000000..a7cce461ef --- /dev/null +++ b/src/app/access-control/access-control-routes.ts @@ -0,0 +1,117 @@ +import { AbstractControl } from '@angular/forms'; +import { + mapToCanActivate, + Route, +} from '@angular/router'; +import { + DYNAMIC_ERROR_MESSAGES_MATCHER, + DynamicErrorMessagesMatcher, +} from '@ng-dynamic-forms/core'; + +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + EPERSON_PATH, + GROUP_PATH, +} from './access-control-routing-paths'; +import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; +import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; +import { GroupFormComponent } from './group-registry/group-form/group-form.component'; +import { GroupPageGuard } from './group-registry/group-page.guard'; +import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; + +/** + * Condition for displaying error messages on email form field + */ +export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = + (control: AbstractControl, model: any, hasFocus: boolean) => { + return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus); + }; + +const providers = [ + { + provide: DYNAMIC_ERROR_MESSAGES_MATCHER, + useValue: ValidateEmailErrorStateMatcher, + }, +]; +export const ROUTES: Route[] = [ + { + path: EPERSON_PATH, + component: EPeopleRegistryComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, + canActivate: mapToCanActivate([SiteAdministratorGuard]), + }, + { + path: `${EPERSON_PATH}/create`, + component: EPersonFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' }, + canActivate: mapToCanActivate([SiteAdministratorGuard]), + }, + { + path: `${EPERSON_PATH}/:id/edit`, + component: EPersonFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + ePerson: EPersonResolver, + }, + providers, + data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' }, + canActivate: mapToCanActivate([SiteAdministratorGuard]), + }, + { + path: GROUP_PATH, + component: GroupsRegistryComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, + canActivate: mapToCanActivate([GroupAdministratorGuard]), + }, + { + path: `${GROUP_PATH}/create`, + component: GroupFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { + title: 'admin.access-control.groups.title.addGroup', + breadcrumbKey: 'admin.access-control.groups.addGroup', + }, + canActivate: mapToCanActivate([GroupAdministratorGuard]), + }, + { + path: `${GROUP_PATH}/:groupId/edit`, + component: GroupFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { + title: 'admin.access-control.groups.title.singleGroup', + breadcrumbKey: 'admin.access-control.groups.singleGroup', + }, + canActivate: mapToCanActivate([GroupPageGuard]), + }, + { + path: 'bulk-access', + component: BulkAccessComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, + canActivate: mapToCanActivate([SiteAdministratorGuard]), + }, +]; diff --git a/src/app/access-control/access-control-routing-paths.ts b/src/app/access-control/access-control-routing-paths.ts index 31f39f1c47..06ae032194 100644 --- a/src/app/access-control/access-control-routing-paths.ts +++ b/src/app/access-control/access-control-routing-paths.ts @@ -1,5 +1,5 @@ -import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAccessControlModuleRoute } from '../app-routing-paths'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; export const EPERSON_PATH = 'epeople'; diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts deleted file mode 100644 index 97d049ad83..0000000000 --- a/src/app/access-control/access-control-routing.module.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; -import { GroupFormComponent } from './group-registry/group-form/group-form.component'; -import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { EPERSON_PATH, GROUP_PATH } from './access-control-routing-paths'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { GroupPageGuard } from './group-registry/group-page.guard'; -import { - GroupAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { - SiteAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; -import { BulkAccessComponent } from './bulk-access/bulk-access.component'; -import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; -import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: EPERSON_PATH, - component: EPeopleRegistryComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, - canActivate: [SiteAdministratorGuard] - }, - { - path: `${EPERSON_PATH}/create`, - component: EPersonFormComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver, - }, - data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' }, - canActivate: [SiteAdministratorGuard], - }, - { - path: `${EPERSON_PATH}/:id/edit`, - component: EPersonFormComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver, - ePerson: EPersonResolver, - }, - data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' }, - canActivate: [SiteAdministratorGuard], - }, - { - path: GROUP_PATH, - component: GroupsRegistryComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, - canActivate: [GroupAdministratorGuard] - }, - { - path: `${GROUP_PATH}/create`, - component: GroupFormComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' }, - canActivate: [GroupAdministratorGuard] - }, - { - path: `${GROUP_PATH}/:groupId/edit`, - component: GroupFormComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, - canActivate: [GroupPageGuard] - }, - { - path: 'bulk-access', - component: BulkAccessComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, - canActivate: [SiteAdministratorGuard] - }, - ]) - ] -}) -/** - * Routing module for the AccessControl section of the admin sidebar - */ -export class AccessControlRoutingModule { - -} diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts deleted file mode 100644 index 3dc4b6cedc..0000000000 --- a/src/app/access-control/access-control.module.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { SharedModule } from '../shared/shared.module'; -import { AccessControlRoutingModule } from './access-control-routing.module'; -import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; -import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; -import { GroupFormComponent } from './group-registry/group-form/group-form.component'; -import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component'; -import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component'; -import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { FormModule } from '../shared/form/form.module'; -import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core'; -import { AbstractControl } from '@angular/forms'; -import { BulkAccessComponent } from './bulk-access/bulk-access.component'; -import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; -import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component'; -import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component'; -import { SearchModule } from '../shared/search/search.module'; -import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module'; - -/** - * Condition for displaying error messages on email form field - */ -export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = - (control: AbstractControl, model: any, hasFocus: boolean) => { - return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus); - }; - -@NgModule({ - imports: [ - CommonModule, - SharedModule, - RouterModule, - AccessControlRoutingModule, - FormModule, - NgbAccordionModule, - SearchModule, - AccessControlFormModule, - ], - exports: [ - MembersListComponent, - ], - declarations: [ - EPeopleRegistryComponent, - EPersonFormComponent, - GroupsRegistryComponent, - GroupFormComponent, - SubgroupsListComponent, - MembersListComponent, - BulkAccessComponent, - BulkAccessBrowseComponent, - BulkAccessSettingsComponent, - ], - providers: [ - { - provide: DYNAMIC_ERROR_MESSAGES_MATCHER, - useValue: ValidateEmailErrorStateMatcher - }, - ] -}) -/** - * This module handles all components related to the access control pages - */ -export class AccessControlModule { - -} diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html index 6e967b53b5..f96ddf4a23 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html @@ -23,10 +23,10 @@ {{'admin.access-control.bulk-access-browse.search.header' | translate}}
- + [showThumbnails]="false">
@@ -37,7 +37,6 @@ { let component: BulkAccessBrowseComponent; @@ -23,7 +35,7 @@ describe('BulkAccessBrowseComponent', () => { const selected1 = new SelectableObject(value1); const selected2 = new SelectableObject(value2); - const testSelection = { id: listID1, selection: [selected1, selected2] } ; + const testSelection = { id: listID1, selection: [selected1, selected2] }; const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']); beforeEach(waitForAsync(() => { @@ -31,14 +43,28 @@ describe('BulkAccessBrowseComponent', () => { imports: [ NgbAccordionModule, NgbNavModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), + BulkAccessBrowseComponent, + ], + providers: [ + { provide: SelectableListService, useValue: selectableListService }, + { provide: ThemeService, useValue: getMockThemeService() }, ], - declarations: [BulkAccessBrowseComponent], - providers: [ { provide: SelectableListService, useValue: selectableListService }, ], schemas: [ - NO_ERRORS_SCHEMA - ] - }).compileComponents(); + NO_ERRORS_SCHEMA, + ], + }) + .overrideComponent(BulkAccessBrowseComponent, { + remove: { + imports: [ + PaginationComponent, + ThemedSearchComponent, + SelectableListItemControlComponent, + ListableObjectComponentLoaderComponent, + ], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -72,8 +98,8 @@ describe('BulkAccessBrowseComponent', () => { 'elementsPerPage': 5, 'totalElements': 2, 'totalPages': 1, - 'currentPage': 1 - }), [selected1, selected2]) ; + 'currentPage': 1, + }), [selected1, selected2]); const rd = createSuccessfulRemoteDataObject(list); expect(component.objectsSelected$.value).toEqual(rd); diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts index e806e729c8..a400742f01 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts @@ -1,19 +1,48 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + NgbAccordionModule, + NgbNavModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgxPaginationModule } from 'ngx-pagination'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; - -import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; -import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; -import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; -import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { PageInfo } from '../../../core/shared/page-info.model'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-configuration.service'; import { hasValue } from '../../../shared/empty.util'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { ListableObjectComponentLoaderComponent } from '../../../shared/object-collection/shared/listable-object/listable-object-component-loader.component'; +import { SelectableListItemControlComponent } from '../../../shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; +import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { PaginationComponent } from '../../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ThemedSearchComponent } from '../../../shared/search/themed-search.component'; +import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; @Component({ selector: 'ds-bulk-access-browse', @@ -22,9 +51,24 @@ import { hasValue } from '../../../shared/empty.util'; providers: [ { provide: SEARCH_CONFIG_SERVICE, - useClass: SearchConfigurationService - } - ] + useClass: SearchConfigurationService, + }, + ], + imports: [ + PaginationComponent, + AsyncPipe, + NgbAccordionModule, + TranslateModule, + NgIf, + NgbNavModule, + ThemedSearchComponent, + BrowserOnlyPipe, + NgForOf, + NgxPaginationModule, + SelectableListItemControlComponent, + ListableObjectComponentLoaderComponent, + ], + standalone: true, }) export class BulkAccessBrowseComponent implements OnInit, OnDestroy { @@ -49,7 +93,7 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { paginationOptions$: BehaviorSubject = new BehaviorSubject(Object.assign(new PaginationComponentOptions(), { id: 'bas', pageSize: 5, - currentPage: 1 + currentPage: 1, })); /** @@ -67,20 +111,20 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { this.subs.push( this.selectableListService.getSelectableList(this.listId).pipe( distinctUntilChanged(), - map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)) - ).subscribe(this.objectsSelected$) + map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)), + ).subscribe(this.objectsSelected$), ); } pageNext() { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: this.paginationOptions$.value.currentPage + 1 + currentPage: this.paginationOptions$.value.currentPage + 1, })); } pagePrev() { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: this.paginationOptions$.value.currentPage - 1 + currentPage: this.paginationOptions$.value.currentPage - 1, })); } @@ -99,12 +143,12 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { elementsPerPage: this.paginationOptions$.value.pageSize, totalElements: list?.selection.length, totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length), - currentPage: this.paginationOptions$.value.currentPage + currentPage: this.paginationOptions$.value.currentPage, }); if (pageInfo.currentPage > pageInfo.totalPages) { pageInfo.currentPage = pageInfo.totalPages; this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: pageInfo.currentPage + currentPage: pageInfo.currentPage, })); } return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || [])); diff --git a/src/app/access-control/bulk-access/bulk-access.component.spec.ts b/src/app/access-control/bulk-access/bulk-access.component.spec.ts index e9b253147d..8bfbe1fe5d 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.spec.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.spec.ts @@ -1,18 +1,23 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; - +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { BulkAccessComponent } from './bulk-access.component'; -import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; -import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { Process } from '../../process-page/processes/process.model'; -import { RouterTestingModule } from '@angular/router/testing'; +import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { ThemeService } from '../../shared/theme-support/theme.service'; +import { BulkAccessComponent } from './bulk-access.component'; +import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; describe('BulkAccessComponent', () => { let component: BulkAccessComponent; @@ -31,35 +36,35 @@ describe('BulkAccessComponent', () => { 'startDate': { 'year': 2026, 'month': 5, - 'day': 31 + 'day': 31, }, - 'endDate': null - } + 'endDate': null, + }, ], 'state': { 'item': { 'toggleStatus': true, - 'accessMode': 'replace' + 'accessMode': 'replace', }, 'bitstream': { 'toggleStatus': false, 'accessMode': '', 'changesLimit': '', - 'selectedBitstreams': [] - } - } + 'selectedBitstreams': [], + }, + }, }; const mockFile = { 'uuids': [ - '1234', '5678' + '1234', '5678', ], - 'file': { } + 'file': { }, }; const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { getValue: jasmine.createSpy('getValue'), - reset: jasmine.createSpy('reset') + reset: jasmine.createSpy('reset'), }); const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; const selectableListState: SelectableListState = { id: 'test', selection }; @@ -71,16 +76,24 @@ describe('BulkAccessComponent', () => { await TestBed.configureTestingModule({ imports: [ RouterTestingModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), + BulkAccessComponent, ], - declarations: [ BulkAccessComponent ], providers: [ { provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock }, { provide: NotificationsService, useValue: NotificationsServiceStub }, - { provide: SelectableListService, useValue: selectableListServiceMock } + { provide: SelectableListService, useValue: selectableListServiceMock }, + { provide: ThemeService, useValue: getMockThemeService() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) + .overrideComponent(BulkAccessComponent, { + remove: { + imports: [ + BulkAccessSettingsComponent, + ], + }, + }) .compileComponents(); }); diff --git a/src/app/access-control/bulk-access/bulk-access.component.ts b/src/app/access-control/bulk-access/bulk-access.component.ts index 04724614cb..bd8e893b59 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.ts @@ -1,17 +1,34 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; +import { + Component, + OnInit, + ViewChild, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; - -import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component'; +import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; @Component({ selector: 'ds-bulk-access', templateUrl: './bulk-access.component.html', - styleUrls: ['./bulk-access.component.scss'] + styleUrls: ['./bulk-access.component.scss'], + imports: [ + TranslateModule, + BulkAccessSettingsComponent, + BulkAccessBrowseComponent, + ], + standalone: true, }) export class BulkAccessComponent implements OnInit { @@ -37,7 +54,7 @@ export class BulkAccessComponent implements OnInit { constructor( private bulkAccessControlService: BulkAccessControlService, - private selectableListService: SelectableListService + private selectableListService: SelectableListService, ) { } @@ -45,8 +62,8 @@ export class BulkAccessComponent implements OnInit { this.subs.push( this.selectableListService.getSelectableList(this.listId).pipe( distinctUntilChanged(), - map((list: SelectableListState) => this.generateIdListBySelectedElements(list)) - ).subscribe(this.objectsSelected$) + map((list: SelectableListState) => this.generateIdListBySelectedElements(list)), + ).subscribe(this.objectsSelected$), ); } @@ -74,12 +91,12 @@ export class BulkAccessComponent implements OnInit { const { file } = this.bulkAccessControlService.createPayloadFile({ bitstreamAccess, itemAccess, - state: settings.state + state: settings.state, }); this.bulkAccessControlService.executeScript( this.objectsSelected$.value || [], - file + file, ).subscribe(); } diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts index 14e0fdefb2..880e1f2472 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts @@ -1,8 +1,13 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; + +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; import { BulkAccessSettingsComponent } from './bulk-access-settings.component'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; describe('BulkAccessSettingsComponent', () => { let component: BulkAccessSettingsComponent; @@ -15,36 +20,39 @@ describe('BulkAccessSettingsComponent', () => { 'startDate': { 'year': 2026, 'month': 5, - 'day': 31 + 'day': 31, }, - 'endDate': null - } + 'endDate': null, + }, ], 'state': { 'item': { 'toggleStatus': true, - 'accessMode': 'replace' + 'accessMode': 'replace', }, 'bitstream': { 'toggleStatus': false, 'accessMode': '', 'changesLimit': '', - 'selectedBitstreams': [] - } - } + 'selectedBitstreams': [], + }, + }, }; const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { getFormValue: jasmine.createSpy('getFormValue'), - reset: jasmine.createSpy('reset') + reset: jasmine.createSpy('reset'), }); beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [NgbAccordionModule, TranslateModule.forRoot()], - declarations: [BulkAccessSettingsComponent], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + imports: [NgbAccordionModule, TranslateModule.forRoot(), BulkAccessSettingsComponent], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(BulkAccessSettingsComponent, { + remove: { imports: [AccessControlFormContainerComponent] }, + }) + .compileComponents(); }); beforeEach(() => { diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts index eecc016245..264cefc708 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts @@ -1,13 +1,25 @@ -import { Component, ViewChild } from '@angular/core'; +import { NgIf } from '@angular/common'; import { - AccessControlFormContainerComponent -} from '../../../shared/access-control-form-container/access-control-form-container.component'; + Component, + ViewChild, +} from '@angular/core'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; @Component({ selector: 'ds-bulk-access-settings', templateUrl: 'bulk-access-settings.component.html', styleUrls: ['./bulk-access-settings.component.scss'], - exportAs: 'dsBulkSettings' + exportAs: 'dsBulkSettings', + imports: [ + NgbAccordionModule, + TranslateModule, + NgIf, + AccessControlFormContainerComponent, + ], + standalone: true, }) export class BulkAccessSettingsComponent { diff --git a/src/app/access-control/epeople-registry/epeople-registry.actions.ts b/src/app/access-control/epeople-registry/epeople-registry.actions.ts index a07ea37df2..e6e7608ba3 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.actions.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.actions.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; + import { EPerson } from '../../core/eperson/models/eperson.model'; import { type } from '../../shared/ngrx/type'; diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index bf7b9a2060..b16d8ac659 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -41,11 +41,10 @@ - + @@ -87,7 +86,7 @@ - diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index e2cee5e935..c636b72d56 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -1,47 +1,75 @@ -import { Router } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + NgbModal, + NgbModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { PageInfo } from '../../core/shared/page-info.model'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { EPeopleRegistryComponent } from './epeople-registry.component'; -import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + EPersonMock, + EPersonMock2, +} from '../../shared/testing/eperson.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { RequestService } from '../../core/data/request.service'; -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 { EPeopleRegistryComponent } from './epeople-registry.component'; +import { EPersonFormComponent } from './eperson-form/eperson-form.component'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; let fixture: ComponentFixture; - let translateService: TranslateService; let builderService: FormBuilderService; - let mockEPeople; + let mockEPeople: EPerson[]; let ePersonDataServiceStub: any; let authorizationService: AuthorizationDataService; - let modalService; + let modalService: NgbModal; + let paginationService: PaginationServiceStub; - let paginationService; - - beforeEach(waitForAsync(() => { + beforeEach(waitForAsync(async () => { jasmine.getEnv().allowRespy(true); mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { @@ -52,7 +80,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); }, getActiveEPerson(): Observable { @@ -67,7 +95,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), [result])); } if (scope === 'metadata') { @@ -76,7 +104,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); } const result = this.allEpeople.find((ePerson: EPerson) => { @@ -86,20 +114,20 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), [result])); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); }, deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { return (ePerson2.uuid !== ePerson.uuid); - }); + }); return observableOf(true); }, editEPerson(ePerson: EPerson) { @@ -113,42 +141,44 @@ describe('EPeopleRegistryComponent', () => { }, getEPeoplePageRouterLink(): string { return '/access-control/epeople'; - } + }, }; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); builderService = getMockFormBuilderService(); - translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); TestBed.configureTestingModule({ - imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), - ], - declarations: [EPeopleRegistryComponent], + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule.withRoutes([]), + TranslateModule.forRoot(), EPeopleRegistryComponent], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: FormBuilderService, useValue: builderService }, - { provide: Router, useValue: new RouterStub() }, + { provide: Router, useValue: new RouterMock() }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, - { provide: PaginationService, useValue: paginationService } + { provide: PaginationService, useValue: paginationService }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(EPeopleRegistryComponent, { + remove: { + imports: [ + EPersonFormComponent, + ThemedLoadingComponent, + PaginationComponent, + ], + }, + }) + .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(EPeopleRegistryComponent); component = fixture.componentInstance; - modalService = (component as any).modalService; + modalService = TestBed.inject(NgbModal); spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); fixture.detectChanges(); }); @@ -158,10 +188,10 @@ describe('EPeopleRegistryComponent', () => { }); it('should display list of ePeople', () => { - const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + const ePeopleIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); expect(ePeopleIdsFound.length).toEqual(2); mockEPeople.map((ePerson: EPerson) => { - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === ePerson.uuid); })).toBeTruthy(); }); @@ -169,7 +199,7 @@ describe('EPeopleRegistryComponent', () => { describe('search', () => { describe('when searching with scope/query (scope metadata)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'metadata', query: EPersonMock2.name }); tick(); @@ -179,14 +209,14 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid); })).toBeTruthy(); }); }); describe('when searching with scope/query (scope email)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'email', query: EPersonMock.email }); tick(); @@ -196,7 +226,7 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid); })).toBeTruthy(); }); @@ -212,7 +242,7 @@ describe('EPeopleRegistryComponent', () => { const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton')); deleteButtons[0].triggerEventHandler('click', { preventDefault: () => {/**/ - } + }, }); tick(); fixture.detectChanges(); @@ -228,19 +258,12 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('delete EPerson button when the isAuthorized returns false', () => { - let ePeopleDeleteButton; - beforeEach(() => { - spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); - component.initialisePage(); - fixture.detectChanges(); - }); - it('should be disabled', () => { - ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); - ePeopleDeleteButton.forEach((deleteButton: DebugElement) => { - expect(deleteButton.nativeElement.disabled).toBe(true); - }); - }); + it('should hide delete EPerson button when the isAuthorized returns false', () => { + spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); + component.initialisePage(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('#epeople tr td div button.delete-button'))).toBeNull(); }); }); diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index 41a6648479..5466ed0152 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -1,32 +1,86 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormBuilder } from '@angular/forms'; -import { Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + ReactiveFormsModule, + UntypedFormBuilder, +} from '@angular/forms'; +import { + Router, + RouterModule, +} from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + Observable, + Subscription, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { getAllSucceededRemoteData, getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { RequestService } from '../../core/data/request.service'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { NoContent } from '../../core/shared/NoContent.model'; import { PaginationService } from '../../core/pagination/pagination.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { getEPersonEditRoute, getEPersonsRoute } from '../access-control-routing-paths'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { + getAllSucceededRemoteData, + getFirstCompletedRemoteData, +} from '../../core/shared/operators'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { hasValue } from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { + getEPersonEditRoute, + getEPersonsRoute, +} from '../access-control-routing-paths'; +import { EPersonFormComponent } from './eperson-form/eperson-form.component'; @Component({ selector: 'ds-epeople-registry', templateUrl: './epeople-registry.component.html', + imports: [ + TranslateModule, + RouterModule, + AsyncPipe, + NgIf, + EPersonFormComponent, + ReactiveFormsModule, + ThemedLoadingComponent, + PaginationComponent, + NgClass, + NgForOf, + ], + standalone: true, }) /** * A component used for managing all existing epeople within the repository. @@ -62,7 +116,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'elp', pageSize: 5, - currentPage: 1 + currentPage: 1, }); // The search form @@ -110,7 +164,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ initialisePage() { this.searching$.next(true); - this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); + this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); this.subs.push(this.ePeople$.pipe( switchMap((epeople: PaginatedList) => { if (epeople.pageInfo.totalElements > 0) { @@ -121,7 +175,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { epersonDtoModel.ableToDelete = authorized; epersonDtoModel.eperson = eperson; return epersonDtoModel; - }) + }), ); })).pipe(map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epeople.pageInfo, dtos); @@ -147,34 +201,34 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { } this.findListOptionsSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((findListOptions) => { - const query: string = data.query; - const scope: string = data.scope; - if (query != null && this.currentSearchQuery !== query) { - void this.router.navigate([getEPersonsRoute()], { - queryParamsHandling: 'merge' - }); - this.currentSearchQuery = query; - this.paginationService.resetPage(this.config.id); - } - if (scope != null && this.currentSearchScope !== scope) { - void this.router.navigate([getEPersonsRoute()], { - queryParamsHandling: 'merge' - }); - this.currentSearchScope = scope; - this.paginationService.resetPage(this.config.id); - - } - return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { - currentPage: findListOptions.currentPage, - elementsPerPage: findListOptions.pageSize + const query: string = data.query; + const scope: string = data.scope; + if (query != null && this.currentSearchQuery !== query) { + void this.router.navigate([getEPersonsRoute()], { + queryParamsHandling: 'merge', }); + this.currentSearchQuery = query; + this.paginationService.resetPage(this.config.id); } + if (scope != null && this.currentSearchScope !== scope) { + void this.router.navigate([getEPersonsRoute()], { + queryParamsHandling: 'merge', + }); + this.currentSearchScope = scope; + this.paginationService.resetPage(this.config.id); + + } + return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + currentPage: findListOptions.currentPage, + elementsPerPage: findListOptions.pageSize, + }); + }, ), getAllSucceededRemoteData(), ).subscribe((peopleRD) => { - this.ePeople$.next(peopleRD.payload); - this.pageInfoState$.next(peopleRD.payload.pageInfo); - } + this.ePeople$.next(peopleRD.payload); + this.pageInfoState$.next(peopleRD.payload.pageInfo); + }, ); } @@ -184,7 +238,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ isActive(eperson: EPerson): Observable { return this.getActiveEPerson().pipe( - map((activeEPerson) => eperson === activeEPerson) + map((activeEPerson) => eperson === activeEPerson), ); } @@ -213,7 +267,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { if (hasValue(ePerson.id)) { this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { if (restResponse.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)})); + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(ePerson) })); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage })); } @@ -244,7 +298,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.searchForm.patchValue({ query: '', }); - this.search({query: ''}); + this.search({ query: '' }); } getEditEPeoplePage(id: string): string { diff --git a/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts index 7158acc79b..6bee3f84e2 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts @@ -1,6 +1,12 @@ -import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from './epeople-registry.actions'; -import { ePeopleRegistryReducer, EPeopleRegistryState } from './epeople-registry.reducers'; import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction, +} from './epeople-registry.actions'; +import { + ePeopleRegistryReducer, + EPeopleRegistryState, +} from './epeople-registry.reducers'; const initialState: EPeopleRegistryState = { editEPerson: null, diff --git a/src/app/access-control/epeople-registry/epeople-registry.reducers.ts b/src/app/access-control/epeople-registry/epeople-registry.reducers.ts index 1e0319f3ba..3bab6769e1 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.reducers.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.reducers.ts @@ -2,7 +2,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPeopleRegistryAction, EPeopleRegistryActionTypes, - EPeopleRegistryEditEPersonAction + EPeopleRegistryEditEPersonAction, } from './epeople-registry.actions'; /** @@ -30,13 +30,13 @@ export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegi case EPeopleRegistryActionTypes.EDIT_EPERSON: { return Object.assign({}, state, { - editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson + editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson, }); } case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: { return Object.assign({}, state, { - editEPerson: null + editEPerson: null, }); } diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index 747d30bb89..b4c0781ac7 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -25,7 +25,7 @@
-
@@ -42,18 +42,17 @@ - +

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

- + @@ -68,7 +67,7 @@ - + {{group.id}} - - - diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index cec1b555ab..f997f5db9c 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -1,24 +1,47 @@ -import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; - -import { CommunityListComponent } from './community-list.component'; -import { CommunityListService, showMoreFlatNode, toFlatNode } from '../community-list-service'; import { CdkTreeModule } from '@angular/cdk/tree'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Community } from '../../core/shared/community.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 { Collection } from '../../core/shared/collection.model'; -import { of as observableOf } from 'rxjs'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { FlatNode } from '../flat-node.model'; import { RouterLinkWithHref } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { Collection } from '../../core/shared/collection.model'; +import { Community } from '../../core/shared/community.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { TruncatableComponent } from '../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { + CommunityListService, + showMoreFlatNode, + toFlatNode, +} from '../community-list-service'; +import { FlatNode } from '../flat-node.model'; +import { CommunityListComponent } from './community-list.component'; + describe('CommunityListComponent', () => { let component: CommunityListComponent; let fixture: ComponentFixture; @@ -28,11 +51,11 @@ describe('CommunityListComponent', () => { uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', name: 'subcommunity1', }), - Object.assign(new Community(), { - id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - name: 'subcommunity2', - }) + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + name: 'subcommunity2', + }), ]; const mockCollectionsPage1 = [ Object.assign(new Collection(), { @@ -44,7 +67,7 @@ describe('CommunityListComponent', () => { id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', name: 'collection2', - }) + }), ]; const mockCollectionsPage2 = [ Object.assign(new Collection(), { @@ -56,7 +79,7 @@ describe('CommunityListComponent', () => { id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', name: 'collection4', - }) + }), ]; const mockTopCommunitiesWithChildrenArrays = [ @@ -87,7 +110,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockSubcommunities1Page1)), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community1', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ), toFlatNode( Object.assign(new Community(), { @@ -96,7 +119,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), name: 'community2', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ), toFlatNode( Object.assign(new Community(), { @@ -105,7 +128,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community3', - }), observableOf(false), 0, false, null + }), observableOf(false), 0, false, null, ), ]; let communityListServiceStub; @@ -184,24 +207,33 @@ describe('CommunityListComponent', () => { } return observableOf(flatnodes); } - } + }, }; TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock + useClass: TranslateLoaderMock, }, }), CdkTreeModule, RouterTestingModule, - RouterLinkWithHref], - declarations: [CommunityListComponent], + RouterLinkWithHref, + CommunityListComponent, + ], providers: [CommunityListComponent, - { provide: CommunityListService, useValue: communityListServiceStub },], + { provide: CommunityListService, useValue: communityListServiceStub }], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) + .overrideComponent(CommunityListComponent, { + remove: { + imports: [ + ThemedLoadingComponent, + TruncatableComponent, + TruncatablePartComponent, + ] }, + }) .compileComponents(); })); @@ -243,7 +275,7 @@ describe('CommunityListComponent', () => { const showMoreLink = fixture.debugElement.query(By.css('.show-more-node .btn-outline-primary')); showMoreLink.triggerEventHandler('click', { preventDefault: () => {/**/ - } + }, }); tick(); fixture.detectChanges(); diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index 031a422469..5819471d7e 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,13 +1,34 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { + CdkTreeModule, + FlatTreeControl, +} from '@angular/cdk/tree'; +import { + AsyncPipe, + NgClass, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { take } from 'rxjs/operators'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CommunityListService} from '../community-list-service'; -import { CommunityListDatasource } from '../community-list-datasource'; -import { FlatTreeControl } from '@angular/cdk/tree'; -import { isEmpty } from '../../shared/empty.util'; -import { FlatNode } from '../flat-node.model'; -import { FindListOptions } from '../../core/data/find-list-options.model'; + import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { + SortDirection, + SortOptions, +} from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { isEmpty } from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { TruncatableComponent } from '../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { CommunityListDatasource } from '../community-list-datasource'; +import { CommunityListService } from '../community-list-service'; +import { FlatNode } from '../flat-node.model'; /** * A tree-structured list of nodes representing the communities, their subCommunities and collections. @@ -17,9 +38,11 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; * Which nodes were expanded is kept in the store, so this persists across pages. */ @Component({ - selector: 'ds-community-list', + selector: 'ds-base-community-list', templateUrl: './community-list.component.html', styleUrls: ['./community-list.component.scss'], + standalone: true, + imports: [NgIf, ThemedLoadingComponent, CdkTreeModule, NgClass, RouterLink, TruncatableComponent, TruncatablePartComponent, AsyncPipe, TranslateModule], }) export class CommunityListComponent implements OnInit, OnDestroy { @@ -27,7 +50,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { public loadingNode: FlatNode; treeControl = new FlatTreeControl( - (node: FlatNode) => node.level, (node: FlatNode) => true + (node: FlatNode) => node.level, (node: FlatNode) => true, ); dataSource: CommunityListDatasource; paginationConfig: FindListOptions; diff --git a/src/app/community-list-page/community-list/themed-community-list.component.ts b/src/app/community-list-page/community-list/themed-community-list.component.ts index 4a986e737c..5340384ed5 100644 --- a/src/app/community-list-page/community-list/themed-community-list.component.ts +++ b/src/app/community-list-page/community-list/themed-community-list.component.ts @@ -1,12 +1,15 @@ +import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { CommunityListComponent } from './community-list.component'; -import { Component } from '@angular/core'; @Component({ - selector: 'ds-themed-community-list', + selector: 'ds-community-list', styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityListComponent], }) export class ThemedCommunityListComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/community-list-page/flat-node.model.ts b/src/app/community-list-page/flat-node.model.ts index 0aabbeb489..125ffc1e59 100644 --- a/src/app/community-list-page/flat-node.model.ts +++ b/src/app/community-list-page/flat-node.model.ts @@ -1,6 +1,7 @@ import { Observable } from 'rxjs'; -import { Community } from '../core/shared/community.model'; + import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model'; /** diff --git a/src/app/community-list-page/themed-community-list-page.component.ts b/src/app/community-list-page/themed-community-list-page.component.ts index 20fa97bedd..d427b1bec1 100644 --- a/src/app/community-list-page/themed-community-list-page.component.ts +++ b/src/app/community-list-page/themed-community-list-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CommunityListPageComponent } from './community-list-page.component'; @@ -6,9 +7,11 @@ import { CommunityListPageComponent } from './community-list-page.component'; * Themed wrapper for CommunityListPageComponent */ @Component({ - selector: 'ds-themed-community-list-page', + selector: 'ds-community-list-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityListPageComponent], }) export class ThemedCommunityListPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/community-page/community-form/community-form.component.ts b/src/app/community-page/community-form/community-form.component.ts index 53f36c30cb..d32d9e408f 100644 --- a/src/app/community-page/community-form/community-form.component.ts +++ b/src/app/community-page/community-form/community-form.component.ts @@ -1,20 +1,39 @@ -import { Component, Input, OnChanges, SimpleChange, SimpleChanges } from '@angular/core'; +import { + AsyncPipe, + NgClass, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnChanges, + SimpleChange, + SimpleChanges, +} from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormControlModel, DynamicFormService, DynamicInputModel, - DynamicTextAreaModel + DynamicTextAreaModel, } from '@ng-dynamic-forms/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { environment } from '../../../environments/environment'; +import { AuthService } from '../../core/auth/auth.service'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; import { Community } from '../../core/shared/community.model'; import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; -import { TranslateService } from '@ngx-translate/core'; +import { ComcolPageLogoComponent } from '../../shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { FormComponent } from '../../shared/form/form.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { RequestService } from '../../core/data/request.service'; -import { ObjectCacheService } from '../../core/cache/object-cache.service'; -import { environment } from '../../../environments/environment'; -import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import { UploaderComponent } from '../../shared/upload/uploader/uploader.component'; +import { VarDirective } from '../../shared/utils/var.directive'; /** * Form used for creating and editing communities @@ -22,7 +41,18 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'ds-community-form', styleUrls: ['../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.scss'], - templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html' + templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html', + standalone: true, + imports: [ + FormComponent, + TranslateModule, + UploaderComponent, + AsyncPipe, + ComcolPageLogoComponent, + NgIf, + NgClass, + VarDirective, + ], }) export class CommunityFormComponent extends ComColFormComponent implements OnChanges { /** @@ -45,10 +75,10 @@ export class CommunityFormComponent extends ComColFormComponent imple name: 'dc.title', required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'Please enter a name for this title' + required: 'Please enter a name for this title', }, }), new DynamicTextAreaModel({ @@ -87,7 +117,7 @@ export class CommunityFormComponent extends ComColFormComponent imple ngOnChanges(changes: SimpleChanges) { const dsoChange: SimpleChange = changes.dso; if (this.dso && dsoChange && !dsoChange.isFirstChange()) { - super.ngOnInit(); + super.ngOnInit(); } } } diff --git a/src/app/community-page/community-form/community-form.module.ts b/src/app/community-page/community-form/community-form.module.ts deleted file mode 100644 index 925d218973..0000000000 --- a/src/app/community-page/community-form/community-form.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { CommunityFormComponent } from './community-form.component'; -import { SharedModule } from '../../shared/shared.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { FormModule } from '../../shared/form/form.module'; - -@NgModule({ - imports: [ - ComcolModule, - FormModule, - SharedModule - ], - declarations: [ - CommunityFormComponent, - ], - exports: [ - CommunityFormComponent - ] -}) -export class CommunityFormModule { - -} diff --git a/src/app/community-page/community-page-administrator.guard.ts b/src/app/community-page/community-page-administrator.guard.ts index fd7ce5f7bf..23d429b484 100644 --- a/src/app/community-page/community-page-administrator.guard.ts +++ b/src/app/community-page/community-page-administrator.guard.ts @@ -1,25 +1,37 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Community } from '../core/shared/community.model'; -import { CommunityPageResolver } from './community-page.resolver'; +import { + ActivatedRouteSnapshot, + ResolveFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../core/auth/auth.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; -import { Observable, of as observableOf } from 'rxjs'; import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; -import { AuthService } from '../core/auth/auth.service'; +import { RemoteData } from '../core/data/remote-data'; +import { Community } from '../core/shared/community.model'; +import { communityPageResolver } from './community-page.resolver'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) /** * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights */ export class CommunityPageAdministratorGuard extends DsoPageSingleFeatureGuard { - constructor(protected resolver: CommunityPageResolver, - protected authorizationService: AuthorizationDataService, + + protected resolver: ResolveFn> = communityPageResolver; + + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(resolver, authorizationService, router, authService); + super(authorizationService, router, authService); } /** diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts new file mode 100644 index 0000000000..656b96c311 --- /dev/null +++ b/src/app/community-page/community-page-routes.ts @@ -0,0 +1,120 @@ +import { + mapToCanActivate, + Route, +} from '@angular/router'; + +import { browseByGuard } from '../browse-by/browse-by-guard'; +import { browseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; +import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; +import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; +import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; +import { MenuItemType } from '../shared/menu/menu-item-type.model'; +import { communityPageResolver } from './community-page.resolver'; +import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; +import { + COMMUNITY_CREATE_PATH, + COMMUNITY_EDIT_PATH, +} from './community-page-routing-paths'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { createCommunityPageGuard } from './create-community-page/create-community-page.guard'; +import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; +import { ThemedCommunityPageComponent } from './themed-community-page.component'; + +export const ROUTES: Route[] = [ + { + path: COMMUNITY_CREATE_PATH, + children: [ + { + path: '', + component: CreateCommunityPageComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + breadcrumbKey: 'community.create', + }, + }, + ], + canActivate: [authenticatedGuard, createCommunityPageGuard], + data: { + breadcrumbQueryParam: 'parent', + }, + resolve: { + breadcrumb: communityBreadcrumbResolver, + }, + runGuardsAndResolvers: 'always', + }, + { + path: ':id', + resolve: { + dso: communityPageResolver, + breadcrumb: communityBreadcrumbResolver, + menu: dsoEditMenuResolver, + }, + runGuardsAndResolvers: 'always', + children: [ + { + path: COMMUNITY_EDIT_PATH, + loadChildren: () => import('./edit-community-page/edit-community-page-routes') + .then((m) => m.ROUTES), + canActivate: mapToCanActivate([CommunityPageAdministratorGuard]), + }, + { + path: 'delete', + pathMatch: 'full', + component: DeleteCommunityPageComponent, + canActivate: [authenticatedGuard], + }, + { + path: '', + component: ThemedCommunityPageComponent, + children: [ + { + path: '', + pathMatch: 'full', + component: ComcolSearchSectionComponent, + }, + { + path: 'subcoms-cols', + pathMatch: 'full', + component: SubComColSectionComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.subcoms-cols' }, + }, + { + path: 'browse/:id', + pathMatch: 'full', + component: ComcolBrowseByComponent, + canActivate: [browseByGuard], + resolve: { + breadcrumb: browseByI18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'browse.metadata' }, + }, + ], + }, + ], + data: { + menu: { + public: [{ + id: 'statistics_community_:id', + active: true, + visible: true, + index: 2, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: 'statistics/communities/:id/', + } as LinkMenuItemModel, + }], + }, + }, + }, +]; diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts deleted file mode 100644 index 5ca544bb54..0000000000 --- a/src/app/community-page/community-page-routing.module.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { CommunityPageResolver } from './community-page.resolver'; -import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; -import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; -import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; -import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; -import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; -import { LinkService } from '../core/cache/builders/link.service'; -import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; -import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; -import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; -import { ThemedCommunityPageComponent } from './themed-community-page.component'; -import { MenuItemType } from '../shared/menu/menu-item-type.model'; -import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; -import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; -import { BrowseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; -import { BrowseByGuard } from '../browse-by/browse-by-guard'; -import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: COMMUNITY_CREATE_PATH, - component: CreateCommunityPageComponent, - canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] - }, - { - path: ':id', - resolve: { - dso: CommunityPageResolver, - breadcrumb: CommunityBreadcrumbResolver, - menu: DSOEditMenuResolver - }, - runGuardsAndResolvers: 'always', - children: [ - { - path: COMMUNITY_EDIT_PATH, - loadChildren: () => import('./edit-community-page/edit-community-page.module') - .then((m) => m.EditCommunityPageModule), - canActivate: [CommunityPageAdministratorGuard] - }, - { - path: 'delete', - pathMatch: 'full', - component: DeleteCommunityPageComponent, - canActivate: [AuthenticatedGuard], - }, - { - path: '', - component: ThemedCommunityPageComponent, - children: [ - { - path: '', - pathMatch: 'full', - component: SubComColSectionComponent, - }, - { - path: 'browse/:id', - pathMatch: 'full', - component: ComcolBrowseByComponent, - canActivate: [BrowseByGuard], - resolve: { - breadcrumb: BrowseByI18nBreadcrumbResolver, - }, - data: { breadcrumbKey: 'browse.metadata' }, - }, - ], - } - ], - data: { - menu: { - public: [{ - id: 'statistics_community_:id', - active: true, - visible: true, - index: 2, - model: { - type: MenuItemType.LINK, - text: 'menu.section.statistics', - link: 'statistics/communities/:id/', - } as LinkMenuItemModel, - }], - }, - }, - }, - ]) - ], - providers: [ - CommunityPageResolver, - CommunityBreadcrumbResolver, - DSOBreadcrumbsService, - LinkService, - CreateCommunityPageGuard, - CommunityPageAdministratorGuard, - ] -}) -export class CommunityPageRoutingModule { - -} diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index b3e577af7d..a695e2019a 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -10,8 +10,8 @@ - - + + @@ -25,8 +25,8 @@
- - + +
@@ -39,5 +39,5 @@
- + diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index 206aa54cb0..bdb3a50c6b 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -1,26 +1,77 @@ -import { mergeMap, filter, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, + RouterModule, + RouterOutlet, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { RemoteData } from '../core/data/remote-data'; -import { Bitstream } from '../core/shared/bitstream.model'; -import { Community } from '../core/shared/community.model'; -import { fadeInOut } from '../shared/animations/fade'; -import { hasValue } from '../shared/empty.util'; -import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; +import { + filter, + map, + mergeMap, +} from 'rxjs/operators'; + import { AuthService } from '../core/auth/auth.service'; +import { DSONameService } from '../core/breadcrumbs/dso-name.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; -import { getCommunityPageRoute } from './community-page-routing-paths'; +import { RemoteData } from '../core/data/remote-data'; import { redirectOn4xx } from '../core/shared/authorized.operators'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; +import { Bitstream } from '../core/shared/bitstream.model'; +import { Community } from '../core/shared/community.model'; +import { getAllSucceededRemoteDataPayload } from '../core/shared/operators'; +import { fadeInOut } from '../shared/animations/fade'; +import { ThemedComcolPageBrowseByComponent } from '../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; +import { ComcolPageContentComponent } from '../shared/comcol/comcol-page-content/comcol-page-content.component'; +import { ThemedComcolPageHandleComponent } from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; +import { ComcolPageHeaderComponent } from '../shared/comcol/comcol-page-header/comcol-page-header.component'; +import { ComcolPageLogoComponent } from '../shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { DsoEditMenuComponent } from '../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { hasValue } from '../shared/empty.util'; +import { ErrorComponent } from '../shared/error/error.component'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { VarDirective } from '../shared/utils/var.directive'; +import { ViewTrackerComponent } from '../statistics/angulartics/dspace/view-tracker.component'; +import { getCommunityPageRoute } from './community-page-routing-paths'; +import { ThemedCollectionPageSubCollectionListComponent } from './sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component'; +import { ThemedCommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component'; @Component({ - selector: 'ds-community-page', + selector: 'ds-base-community-page', styleUrls: ['./community-page.component.scss'], templateUrl: './community-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut] + animations: [fadeInOut], + imports: [ + ComcolPageContentComponent, + ErrorComponent, + ThemedLoadingComponent, + NgIf, + TranslateModule, + ThemedCommunityPageSubCommunityListComponent, + ThemedCollectionPageSubCollectionListComponent, + ThemedComcolPageBrowseByComponent, + DsoEditMenuComponent, + ThemedComcolPageHandleComponent, + ComcolPageLogoComponent, + ComcolPageHeaderComponent, + AsyncPipe, + ViewTrackerComponent, + VarDirective, + RouterOutlet, + RouterModule, + ], + standalone: true, }) /** * This component represents a detail page for a single community @@ -59,7 +110,7 @@ export class CommunityPageComponent implements OnInit { ngOnInit(): void { this.communityRD$ = this.route.data.pipe( map((data) => data.dso as RemoteData), - redirectOn4xx(this.router, this.authService) + redirectOn4xx(this.router, this.authService), ); this.logoRD$ = this.communityRD$.pipe( map((rd: RemoteData) => rd.payload), @@ -67,7 +118,7 @@ export class CommunityPageComponent implements OnInit { mergeMap((community: Community) => community.logo)); this.communityPageRoute$ = this.communityRD$.pipe( getAllSucceededRemoteDataPayload(), - map((community) => getCommunityPageRoute(community.id)) + map((community) => getCommunityPageRoute(community.id)), ); this.isCommunityAdmin$ = this.authorizationDataService.isAuthorized(FeatureID.IsCommunityAdmin); } diff --git a/src/app/community-page/community-page.module.ts b/src/app/community-page/community-page.module.ts deleted file mode 100644 index 5ebd7c3057..0000000000 --- a/src/app/community-page/community-page.module.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { SharedModule } from '../shared/shared.module'; - -import { CommunityPageComponent } from './community-page.component'; -import { CommunityPageSubCollectionListComponent } from './sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component'; -import { CommunityPageRoutingModule } from './community-page-routing.module'; -import { CommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component'; -import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; -import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; -import { StatisticsModule } from '../statistics/statistics.module'; -import { CommunityFormModule } from './community-form/community-form.module'; -import { ThemedCommunityPageComponent } from './themed-community-page.component'; -import { ComcolModule } from '../shared/comcol/comcol.module'; -import { - ThemedCommunityPageSubCommunityListComponent -} from './sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component'; -import { - ThemedCollectionPageSubCollectionListComponent -} from './sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component'; -import { DsoPageModule } from '../shared/dso-page/dso-page.module'; -import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; -import { BrowseByPageModule } from '../browse-by/browse-by-page.module'; - -const DECLARATIONS = [ - CommunityPageComponent, - ThemedCommunityPageComponent, - ThemedCommunityPageSubCommunityListComponent, - CommunityPageSubCollectionListComponent, - ThemedCollectionPageSubCollectionListComponent, - CommunityPageSubCommunityListComponent, - CreateCommunityPageComponent, - DeleteCommunityPageComponent, - SubComColSectionComponent, -]; - -@NgModule({ - imports: [ - CommonModule, - SharedModule, - CommunityPageRoutingModule, - StatisticsModule.forRoot(), - CommunityFormModule, - ComcolModule, - DsoPageModule, - BrowseByPageModule, - ], - declarations: [ - ...DECLARATIONS - ], - exports: [ - ...DECLARATIONS - ] -}) - -export class CommunityPageModule { - -} diff --git a/src/app/community-page/community-page.resolver.spec.ts b/src/app/community-page/community-page.resolver.spec.ts index f181dbfff6..e429ecf17c 100644 --- a/src/app/community-page/community-page.resolver.spec.ts +++ b/src/app/community-page/community-page.resolver.spec.ts @@ -1,32 +1,34 @@ +import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { CommunityPageResolver } from './community-page.resolver'; -import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -describe('CommunityPageResolver', () => { +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { communityPageResolver } from './community-page.resolver'; + +describe('communityPageResolver', () => { describe('resolve', () => { - let resolver: CommunityPageResolver; + let resolver: any; let communityService: any; let store: any; const uuid = '1234-65487-12354-1235'; beforeEach(() => { communityService = { - findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findById: (id: string) => createSuccessfulRemoteDataObject$({ id }), }; store = jasmine.createSpyObj('store', { dispatch: {}, }); - resolver = new CommunityPageResolver(communityService, store); + resolver = communityPageResolver; }); it('should resolve a community with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, { url: 'current-url' } as any) + (resolver({ params: { id: uuid } } as any, { url: 'current-url' } as any, communityService, store) as Observable) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/community-page/community-page.resolver.ts b/src/app/community-page/community-page.resolver.ts index 01de9294f3..b8820629e7 100644 --- a/src/app/community-page/community-page.resolver.ts +++ b/src/app/community-page/community-page.resolver.ts @@ -1,13 +1,22 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; -import { RemoteData } from '../core/data/remote-data'; -import { Community } from '../core/shared/community.model'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../core/shared/operators'; -import { ResolvedAction } from '../core/resolving/resolver.actions'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { AppState } from '../app.reducer'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { RemoteData } from '../core/data/remote-data'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; +import { Community } from '../core/shared/community.model'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { + followLink, + FollowLinkConfig, +} from '../shared/utils/follow-link-config.model'; /** * The self links defined in this list are expected to be requested somewhere in the near future @@ -17,41 +26,36 @@ export const COMMUNITY_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ followLink('logo'), followLink('subcommunities'), followLink('collections'), - followLink('parentCommunity') + followLink('parentCommunity'), ]; /** - * This class represents a resolver that requests a specific community before the route is activated + * Method for resolving a community based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {CommunityDataService} communityService + * @param {Store} store + * @returns Observable<> Emits the found community based on the parameters in the current route, + * or an error if something went wrong */ -@Injectable() -export class CommunityPageResolver implements Resolve> { - constructor( - private communityService: CommunityDataService, - private store: Store - ) { - } +export const communityPageResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + communityService: CommunityDataService = inject(CommunityDataService), + store: Store = inject(Store), +): Observable> => { + const communityRD$ = communityService.findById( + route.params.id, + true, + false, + ...COMMUNITY_PAGE_LINKS_TO_FOLLOW, + ).pipe( + getFirstCompletedRemoteData(), + ); - /** - * Method for resolving a community based on the parameters in the current route - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found community based on the parameters in the current route, - * or an error if something went wrong - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - const communityRD$ = this.communityService.findById( - route.params.id, - true, - false, - ...COMMUNITY_PAGE_LINKS_TO_FOLLOW - ).pipe( - getFirstCompletedRemoteData(), - ); + communityRD$.subscribe((communityRD: RemoteData) => { + store.dispatch(new ResolvedAction(state.url, communityRD.payload)); + }); - communityRD$.subscribe((communityRD: RemoteData) => { - this.store.dispatch(new ResolvedAction(state.url, communityRD.payload)); - }); - - return communityRD$; - } -} + return communityRD$; +}; diff --git a/src/app/community-page/create-community-page/create-community-page.component.html b/src/app/community-page/create-community-page/create-community-page.component.html index 666f8e2ce0..219f6470b0 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.html +++ b/src/app/community-page/create-community-page/create-community-page.component.html @@ -1,9 +1,10 @@ -
+
-

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

+

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

@@ -11,3 +12,7 @@ [isCreation]="true" (back)="navigateToHome()">
+ +
+ +
diff --git a/src/app/community-page/create-community-page/create-community-page.component.spec.ts b/src/app/community-page/create-community-page/create-community-page.component.spec.ts index fbff82efd8..062c0ea062 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.spec.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.spec.ts @@ -1,17 +1,24 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RouteService } from '../../core/services/route.service'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { CreateCommunityPageComponent } from './create-community-page.component'; +import { RequestService } from '../../core/data/request.service'; +import { RouteService } from '../../core/services/route.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { RequestService } from '../../core/data/request.service'; +import { CommunityFormComponent } from '../community-form/community-form.component'; +import { CreateCommunityPageComponent } from './create-community-page.component'; describe('CreateCommunityPageComponent', () => { let comp: CreateCommunityPageComponent; @@ -19,17 +26,23 @@ describe('CreateCommunityPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [CreateCommunityPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, CreateCommunityPageComponent], providers: [ { provide: CommunityDataService, useValue: { findById: () => observableOf({}) } }, { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, - { provide: RequestService, useValue: {} } + { provide: RequestService, useValue: {} }, + { provide: AuthService, useValue: new AuthServiceMock() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(CreateCommunityPageComponent, { + remove: { + imports: [CommunityFormComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/community-page/create-community-page/create-community-page.component.ts b/src/app/community-page/create-community-page/create-community-page.component.ts index eea0908388..082f6c4f0b 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.ts @@ -1,13 +1,24 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { RouteService } from '../../core/services/route.service'; import { Router } from '@angular/router'; -import { CreateComColPageComponent } from '../../shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; +import { RouteService } from '../../core/services/route.service'; +import { Community } from '../../core/shared/community.model'; +import { CreateComColPageComponent } from '../../shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { CommunityFormComponent } from '../community-form/community-form.component'; /** * Component that represents the page where a user can create a new Community @@ -15,7 +26,16 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-create-community', styleUrls: ['./create-community-page.component.scss'], - templateUrl: './create-community-page.component.html' + templateUrl: './create-community-page.component.html', + imports: [ + CommunityFormComponent, + TranslateModule, + VarDirective, + NgIf, + AsyncPipe, + ThemedLoadingComponent, + ], + standalone: true, }) export class CreateCommunityPageComponent extends CreateComColPageComponent { protected frontendURL = '/communities/'; @@ -28,7 +48,7 @@ export class CreateCommunityPageComponent extends CreateComColPageComponent { +import { Community } from '../../core/shared/community.model'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { createCommunityPageGuard } from './create-community-page.guard'; + +describe('createCommunityPageGuard', () => { describe('canActivate', () => { - let guard: CreateCommunityPageGuard; + let guard: any; let router; let communityDataServiceStub: any; @@ -20,46 +24,46 @@ describe('CreateCommunityPageGuard', () => { } else if (id === 'error-id') { return createFailedRemoteDataObject$('not found', 404); } - } + }, }; router = new RouterMock(); - guard = new CreateCommunityPageGuard(router, communityDataServiceStub); + guard = createCommunityPageGuard; }); it('should return true when the parent ID resolves to a community', () => { - guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined) + guard({ queryParams: { parent: 'valid-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); it('should return true when no parent ID has been provided', () => { - guard.canActivate({ queryParams: { } } as any, undefined) + guard({ queryParams: { } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); it('should return false when the parent ID does not resolve to a community', () => { - guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined) + guard({ queryParams: { parent: 'invalid-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); it('should return false when the parent ID resolves to an error response', () => { - guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined) + guard({ queryParams: { parent: 'error-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); }); diff --git a/src/app/community-page/create-community-page/create-community-page.guard.ts b/src/app/community-page/create-community-page/create-community-page.guard.ts index 835fbb6589..c3ee8c7091 100644 --- a/src/app/community-page/create-community-page/create-community-page.guard.ts +++ b/src/app/community-page/create-community-page/create-community-page.guard.ts @@ -1,44 +1,52 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + tap, +} from 'rxjs/operators'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { map, tap } from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; /** - * Prevent creation of a community with an invalid parent community provided - * @class CreateCommunityPageGuard + * True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community + * Reroutes to a 404 page when the page cannot be activated */ -@Injectable() -export class CreateCommunityPageGuard implements CanActivate { - public constructor(private router: Router, private communityService: CommunityDataService) { +export const createCommunityPageGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + communityService: CommunityDataService = inject(CommunityDataService), + router: Router = inject(Router), +): Observable => { + const parentID = route.queryParams.parent; + if (hasNoValue(parentID)) { + return observableOf(true); } - /** - * True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community - * Reroutes to a 404 page when the page cannot be activated - * @method canActivate - */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const parentID = route.queryParams.parent; - if (hasNoValue(parentID)) { - return observableOf(true); - } - - return this.communityService.findById(parentID) - .pipe( - getFirstCompletedRemoteData(), - map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), - tap((isValid: boolean) => { - if (!isValid) { - this.router.navigate(['/404']); - } + return communityService.findById(parentID) + .pipe( + getFirstCompletedRemoteData(), + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + router.navigate(['/404']); } - ) + }, + ), ); - } -} +}; diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.html b/src/app/community-page/delete-community-page/delete-community-page.component.html index 1e71fb7f49..b241a7027c 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.html +++ b/src/app/community-page/delete-community-page/delete-community-page.component.html @@ -11,7 +11,7 @@
diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts b/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts index 55d0508c10..524f3e3124 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts @@ -1,17 +1,21 @@ import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SharedModule } from '../../shared/shared.module'; -import { DeleteCommunityPageComponent } from './delete-community-page.component'; -import { RequestService } from '../../core/data/request.service'; + import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DeleteCommunityPageComponent } from './delete-community-page.component'; describe('DeleteCommunityPageComponent', () => { let comp: DeleteCommunityPageComponent; @@ -19,16 +23,15 @@ describe('DeleteCommunityPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [DeleteCommunityPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, DeleteCommunityPageComponent], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, - { provide: RequestService, useValue: {}} + { provide: RequestService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.ts b/src/app/community-page/delete-community-page/delete-community-page.component.ts index 65b7c81b38..f35e2d6bd2 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.ts @@ -1,11 +1,23 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Community } from '../../core/shared/community.model'; import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { VarDirective } from '../../shared/utils/var.directive'; /** * Component that represents the page where a user can delete an existing Community @@ -13,7 +25,14 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-delete-community', styleUrls: ['./delete-community-page.component.scss'], - templateUrl: './delete-community-page.component.html' + templateUrl: './delete-community-page.component.html', + imports: [ + TranslateModule, + AsyncPipe, + VarDirective, + NgIf, + ], + standalone: true, }) export class DeleteCommunityPageComponent extends DeleteComColPageComponent { protected frontendURL = '/communities/'; diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts index d895cfd820..28879ed7ab 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts @@ -1,25 +1,80 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + of as observableOf, + of, +} from 'rxjs'; +import { Community } from '../../../core/shared/community.model'; +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { CommunityAccessControlComponent } from './community-access-control.component'; -xdescribe('CommunityAccessControlComponent', () => { +describe('CommunityAccessControlComponent', () => { let component: CommunityAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }], + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID', + }, + }, + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity), + }), + }, + }, + }; + + beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CommunityAccessControlComponent ] + imports: [CommunityAccessControlComponent], + providers: [{ + provide: ActivatedRoute, useValue: routeStub, + }], }) - .compileComponents(); + .overrideComponent(CommunityAccessControlComponent, { + remove: { + imports: [AccessControlFormContainerComponent], + }, + }) + .compileComponents(); }); + beforeEach(() => { fixture = TestBed.createComponent(CommunityAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts index 8a216e38df..a0e094e21d 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts @@ -1,15 +1,30 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; + +import { RemoteData } from '../../../core/data/remote-data'; import { Community } from '../../../core/shared/community.model'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; @Component({ selector: 'ds-community-access-control', templateUrl: './community-access-control.component.html', styleUrls: ['./community-access-control.component.scss'], + imports: [ + AccessControlFormContainerComponent, + NgIf, + AsyncPipe, + ], + standalone: true, }) export class CommunityAccessControlComponent implements OnInit { itemRD$: Observable>; @@ -18,7 +33,7 @@ export class CommunityAccessControlComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.parent.parent.data.pipe( - map((data) => data.dso) + map((data) => data.dso), ).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts index 719cf83a26..921bbf0cfd 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts @@ -1,15 +1,22 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; - import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; +import { Collection } from '../../../core/shared/collection.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ResourcePoliciesComponent } from '../../../shared/resource-policies/resource-policies.component'; import { CommunityAuthorizationsComponent } from './community-authorizations.component'; -import { Collection } from '../../../core/shared/collection.model'; describe('CommunityAuthorizationsComponent', () => { let comp: CommunityAuthorizationsComponent; @@ -19,8 +26,8 @@ describe('CommunityAuthorizationsComponent', () => { uuid: 'community', id: 'community', _links: { - self: { href: 'community-selflink' } - } + self: { href: 'community-selflink' }, + }, }); const communityRD = createSuccessfulRemoteDataObject(community); @@ -29,25 +36,31 @@ describe('CommunityAuthorizationsComponent', () => { parent: { parent: { data: observableOf({ - dso: communityRD - }) - } - } + dso: communityRD, + }), + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - CommonModule + CommonModule, + CommunityAuthorizationsComponent, ], - declarations: [CommunityAuthorizationsComponent], providers: [ { provide: ActivatedRoute, useValue: routeStub }, ChangeDetectorRef, CommunityAuthorizationsComponent, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); + }) + .overrideComponent(CommunityAuthorizationsComponent, { + remove: { + imports: [ResourcePoliciesComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts index 7a9f224311..3e42a830be 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts @@ -1,13 +1,27 @@ -import { Component, OnInit } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { ResourcePoliciesComponent } from '../../../shared/resource-policies/resource-policies.component'; @Component({ selector: 'ds-community-authorizations', templateUrl: './community-authorizations.component.html', + imports: [ + ResourcePoliciesComponent, + AsyncPipe, + ], + standalone: true, }) /** * Component that handles the community Authorizations @@ -25,7 +39,7 @@ export class CommunityAuthorizationsComponent impl * @param {ActivatedRoute} route */ constructor( - private route: ActivatedRoute + private route: ActivatedRoute, ) { } diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts b/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts index 1b1ee2c9f9..541308c942 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts @@ -1,12 +1,21 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { CommunityCurateComponent } from './community-curate.component'; import { Community } from '../../../core/shared/community.model'; +import { CurationFormComponent } from '../../../curation-form/curation-form.component'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { CommunityCurateComponent } from './community-curate.component'; describe('CommunityCurateComponent', () => { let comp: CommunityCurateComponent; @@ -17,31 +26,36 @@ describe('CommunityCurateComponent', () => { let dsoNameService; const community = Object.assign(new Community(), { - metadata: {'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} + metadata: { 'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1' }] }, }); beforeEach(waitForAsync(() => { routeStub = { parent: { data: observableOf({ - dso: createSuccessfulRemoteDataObject(community) - }) - } + dso: createSuccessfulRemoteDataObject(community), + }), + }, }; dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: 'Community Name' + getName: 'Community Name', }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [CommunityCurateComponent], + imports: [TranslateModule.forRoot(), CommunityCurateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: DSONameService, useValue: dsoNameService} + { provide: ActivatedRoute, useValue: routeStub }, + { provide: DSONameService, useValue: dsoNameService }, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }).compileComponents(); + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(CommunityCurateComponent, { + remove: { + imports: [CurationFormComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -58,7 +72,7 @@ describe('CommunityCurateComponent', () => { }); it('should contain the community information provided in the route', () => { comp.dsoRD$.subscribe((value) => { - expect(value.payload.handle + expect(value.payload.handle, ).toEqual('123456789/1'); }); comp.communityName$.subscribe((value) => { diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts b/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts index 8ae04af8f1..fd4d240827 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts @@ -1,10 +1,21 @@ -import { Component, OnInit } from '@angular/core'; -import { Community } from '../../../core/shared/community.model'; +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { filter, map, take } from 'rxjs/operators'; -import { RemoteData } from '../../../core/data/remote-data'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; +import { CurationFormComponent } from '../../../curation-form/curation-form.component'; import { hasValue } from '../../../shared/empty.util'; /** @@ -13,6 +24,12 @@ import { hasValue } from '../../../shared/empty.util'; @Component({ selector: 'ds-community-curate', templateUrl: './community-curate.component.html', + imports: [ + CurationFormComponent, + TranslateModule, + AsyncPipe, + ], + standalone: true, }) export class CommunityCurateComponent implements OnInit { @@ -35,7 +52,7 @@ export class CommunityCurateComponent implements OnInit { filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { return this.dsoNameService.getName(rd.payload); - }) + }), ); } diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts index c597fac0bd..b82beaa3f7 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts @@ -1,15 +1,20 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../shared/shared.module'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute } from '@angular/router'; -import { of as observableOf } from 'rxjs'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CommunityMetadataComponent } from './community-metadata.component'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + import { CommunityDataService } from '../../../core/data/community-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { CommunityFormComponent } from '../../community-form/community-form.component'; +import { CommunityMetadataComponent } from './community-metadata.component'; describe('CommunityMetadataComponent', () => { let comp: CommunityMetadataComponent; @@ -17,15 +22,20 @@ describe('CommunityMetadataComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [CommunityMetadataComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, CommunityMetadataComponent], providers: [ { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: { payload: {} } }) } } }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() } + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(CommunityMetadataComponent, { + remove: { + imports: [CommunityFormComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts index a2dbfa6eb6..8001bd2969 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts @@ -1,17 +1,28 @@ +import { AsyncPipe } from '@angular/common'; import { Component } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Community } from '../../../core/shared/community.model'; -import { CommunityDataService } from '../../../core/data/community-data.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { Community } from '../../../core/shared/community.model'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CommunityFormComponent } from '../../community-form/community-form.component'; + /** * Component for editing a community's metadata */ @Component({ selector: 'ds-community-metadata', templateUrl: './community-metadata.component.html', + imports: [ + CommunityFormComponent, + AsyncPipe, + ], + standalone: true, }) export class CommunityMetadataComponent extends ComcolMetadataComponent { protected frontendURL = '/communities/'; @@ -22,7 +33,7 @@ export class CommunityMetadataComponent extends ComcolMetadataComponent { @@ -39,10 +47,10 @@ describe('CommunityRolesComponent', () => { href: 'adminGroup link', }, }, - }) + }), ), - }) - } + }), + }, }; const requestService = { @@ -55,13 +63,9 @@ describe('CommunityRolesComponent', () => { TestBed.configureTestingModule({ imports: [ - ComcolModule, - SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), - NoopAnimationsModule - ], - declarations: [ + NoopAnimationsModule, CommunityRolesComponent, ], providers: [ @@ -69,9 +73,9 @@ describe('CommunityRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, - { provide: NotificationsService, useClass: NotificationsServiceStub } + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(CommunityRolesComponent); diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts index 9468aa7048..2e85cbe4c3 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts @@ -1,11 +1,26 @@ -import { Component, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgForOf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; -import { Community } from '../../../core/shared/community.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; import { HALLink } from '../../../core/shared/hal-link.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; +import { ComcolRoleComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component'; /** * Component for managing a community's roles @@ -13,6 +28,12 @@ import { HALLink } from '../../../core/shared/hal-link.model'; @Component({ selector: 'ds-community-roles', templateUrl: './community-roles.component.html', + imports: [ + ComcolRoleComponent, + AsyncPipe, + NgForOf, + ], + standalone: true, }) export class CommunityRolesComponent implements OnInit { diff --git a/src/app/community-page/edit-community-page/edit-community-page-routes.ts b/src/app/community-page/edit-community-page/edit-community-page-routes.ts new file mode 100644 index 0000000000..a15312a216 --- /dev/null +++ b/src/app/community-page/edit-community-page/edit-community-page-routes.ts @@ -0,0 +1,91 @@ +import { + mapToCanActivate, + Route, +} from '@angular/router'; + +import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { resourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { resourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; +import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; +import { CommunityCurateComponent } from './community-curate/community-curate.component'; +import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; +import { CommunityRolesComponent } from './community-roles/community-roles.component'; +import { EditCommunityPageComponent } from './edit-community-page.component'; + +/** + * Routing module that handles the routing for the Edit Community page administrator functionality + */ + +export const ROUTES: Route[] = [ + { + path: '', + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.edit' }, + component: EditCommunityPageComponent, + canActivate: mapToCanActivate([CommunityAdministratorGuard]), + children: [ + { + path: '', + redirectTo: 'metadata', + pathMatch: 'full', + }, + { + path: 'metadata', + component: CommunityMetadataComponent, + data: { + title: 'community.edit.tabs.metadata.title', + hideReturnButton: true, + showBreadcrumbs: true, + }, + }, + { + path: 'roles', + component: CommunityRolesComponent, + data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true }, + }, + { + path: 'curate', + component: CommunityCurateComponent, + data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true }, + }, + { + path: 'access-control', + component: CommunityAccessControlComponent, + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true }, + }, + { + path: 'authorizations', + data: { showBreadcrumbs: true }, + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: resourcePolicyTargetResolver, + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title' }, + }, + { + path: 'edit', + resolve: { + resourcePolicy: resourcePolicyResolver, + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title' }, + }, + { + path: '', + component: CommunityAuthorizationsComponent, + data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true, hideReturnButton: true }, + }, + ], + }, + ], + }, +]; diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts b/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts index 3a4c3351c3..f099f6fc2a 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts @@ -1,13 +1,17 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { EditCommunityPageComponent } from './edit-community-page.component'; + import { CommunityDataService } from '../../core/data/community-data.service'; +import { EditCommunityPageComponent } from './edit-community-page.component'; describe('EditCommunityPageComponent', () => { let comp: EditCommunityPageComponent; @@ -15,36 +19,35 @@ describe('EditCommunityPageComponent', () => { const routeStub = { data: observableOf({ - dso: { payload: {} } + dso: { payload: {} }, }), routeConfig: { children: [ { path: 'mockUrl', data: { - hideReturnButton: false - } - } - ] + hideReturnButton: false, + }, + }, + ], }, snapshot: { firstChild: { routeConfig: { - path: 'mockUrl' - } - } - } + path: 'mockUrl', + }, + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [EditCommunityPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, EditCommunityPageComponent], providers: [ { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: routeStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.ts b/src/app/community-page/edit-community-page/edit-community-page.component.ts index 54a6ee4944..194976c3a8 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.ts @@ -1,6 +1,19 @@ +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; +import { + ActivatedRoute, + Router, + RouterLink, + RouterOutlet, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Community } from '../../core/shared/community.model'; -import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { getCommunityPageRoute } from '../community-page-routing-paths'; @@ -9,14 +22,24 @@ import { getCommunityPageRoute } from '../community-page-routing-paths'; */ @Component({ selector: 'ds-edit-community', - templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html', + standalone: true, + imports: [ + RouterLink, + TranslateModule, + NgClass, + NgForOf, + RouterOutlet, + NgIf, + AsyncPipe, + ], }) export class EditCommunityPageComponent extends EditComColPageComponent { type = 'community'; public constructor( protected router: Router, - protected route: ActivatedRoute + protected route: ActivatedRoute, ) { super(router, route); } diff --git a/src/app/community-page/edit-community-page/edit-community-page.module.ts b/src/app/community-page/edit-community-page/edit-community-page.module.ts deleted file mode 100644 index 5190d6a008..0000000000 --- a/src/app/community-page/edit-community-page/edit-community-page.module.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { SharedModule } from '../../shared/shared.module'; -import { EditCommunityPageRoutingModule } from './edit-community-page.routing.module'; -import { EditCommunityPageComponent } from './edit-community-page.component'; -import { CommunityCurateComponent } from './community-curate/community-curate.component'; -import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; -import { CommunityRolesComponent } from './community-roles/community-roles.component'; -import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; -import { CommunityFormModule } from '../community-form/community-form.module'; -import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; -import { - AccessControlFormModule -} from '../../shared/access-control-form-container/access-control-form.module'; - -/** - * Module that contains all components related to the Edit Community page administrator functionality - */ -@NgModule({ - imports: [ - CommonModule, - SharedModule, - EditCommunityPageRoutingModule, - CommunityFormModule, - ComcolModule, - ResourcePoliciesModule, - AccessControlFormModule, - ], - declarations: [ - EditCommunityPageComponent, - CommunityCurateComponent, - CommunityMetadataComponent, - CommunityRolesComponent, - CommunityAuthorizationsComponent, - CommunityAccessControlComponent - ] -}) -export class EditCommunityPageModule { - -} diff --git a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts b/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts deleted file mode 100644 index 994c6b5e96..0000000000 --- a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { EditCommunityPageComponent } from './edit-community-page.component'; -import { RouterModule } from '@angular/router'; -import { NgModule } from '@angular/core'; -import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; -import { CommunityRolesComponent } from './community-roles/community-roles.component'; -import { CommunityCurateComponent } from './community-curate/community-curate.component'; -import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; -import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; -import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; -import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; -import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; -import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; -import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; - -/** - * Routing module that handles the routing for the Edit Community page administrator functionality - */ -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: '', - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { breadcrumbKey: 'community.edit' }, - component: EditCommunityPageComponent, - canActivate: [CommunityAdministratorGuard], - children: [ - { - path: '', - redirectTo: 'metadata', - pathMatch: 'full' - }, - { - path: 'metadata', - component: CommunityMetadataComponent, - data: { - title: 'community.edit.tabs.metadata.title', - hideReturnButton: true, - showBreadcrumbs: true - } - }, - { - path: 'roles', - component: CommunityRolesComponent, - data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true } - }, - { - path: 'curate', - component: CommunityCurateComponent, - data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true } - }, - { - path: 'access-control', - component: CommunityAccessControlComponent, - data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } - }, - /*{ - path: 'authorizations', - component: CommunityAuthorizationsComponent, - data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true } - },*/ - { - path: 'authorizations', - data: { showBreadcrumbs: true }, - children: [ - { - path: 'create', - resolve: { - resourcePolicyTarget: ResourcePolicyTargetResolver - }, - component: ResourcePolicyCreateComponent, - data: { title: 'resource-policies.create.page.title' } - }, - { - path: 'edit', - resolve: { - resourcePolicy: ResourcePolicyResolver - }, - component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title' } - }, - { - path: '', - component: CommunityAuthorizationsComponent, - data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true, hideReturnButton: true } - } - ] - } - ] - } - ]) - ], - providers: [ - ResourcePolicyResolver, - ResourcePolicyTargetResolver - ] -}) -export class EditCommunityPageRoutingModule { - -} diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html index b5fbf1a01d..59d7b3bb5e 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html @@ -9,5 +9,5 @@ - + diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts index 8c4af30991..dc4ab52081 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -1,36 +1,41 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RouterTestingModule } from '@angular/router/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - +import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; -import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -import { Community } from '../../../../core/shared/community.model'; -import { SharedModule } from '../../../../shared/shared.module'; import { CollectionDataService } from '../../../../core/data/collection-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; -import { PageInfo } from '../../../../core/shared/page-info.model'; -import { HostWindowService } from '../../../../shared/host-window.service'; -import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; -import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; -import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; -import { ThemeService } from '../../../../shared/theme-support/theme.service'; -import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../../core/data/find-list-options.model'; -import { GroupDataService } from '../../../../core/eperson/group-data.service'; -import { LinkHeadService } from '../../../../core/services/link-head.service'; import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; -import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { LinkHeadService } from '../../../../core/services/link-head.service'; +import { Community } from '../../../../core/shared/community.model'; import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; -import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { HostWindowService } from '../../../../shared/host-window.service'; +import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; +import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { ThemeService } from '../../../../shared/theme-support/theme.service'; +import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -describe('CommunityPageSubCollectionList Component', () => { +describe('CommunityPageSubCollectionListComponent', () => { let comp: CommunityPageSubCollectionListComponent; let fixture: ComponentFixture; let collectionDataServiceStub: any; @@ -41,67 +46,67 @@ describe('CommunityPageSubCollectionList Component', () => { id: '123456789-1', metadata: { 'dc.title': [ - { language: 'en_US', value: 'Collection 1' } - ] - } + { language: 'en_US', value: 'Collection 1' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 2' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-3', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 3' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-4', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 4' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-5', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 5' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-6', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 6' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-7', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 7' }, + ], + }, }), - Object.assign(new Community(), { - id: '123456789-2', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 2' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-3', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 3' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-4', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 4' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-5', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 5' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-6', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 6' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-7', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 7' } - ] - } - }) ]; const mockCommunity = Object.assign(new Community(), { id: '123456789', metadata: { 'dc.title': [ - { language: 'en_US', value: 'Test title' } - ] - } + { language: 'en_US', value: 'Test title' }, + ], + }, }); collectionDataServiceStub = { @@ -119,7 +124,7 @@ describe('CommunityPageSubCollectionList Component', () => { } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCollList.slice(startPageIndex, endPageIndex))); - } + }, }; const paginationService = new PaginationServiceStub(); @@ -127,7 +132,7 @@ describe('CommunityPageSubCollectionList Component', () => { themeService = getMockThemeService(); const linkHeadService = jasmine.createSpyObj('linkHeadService', { - addTag: '' + addTag: '', }); const groupDataService = jasmine.createSpyObj('groupsDataService', { @@ -140,21 +145,20 @@ describe('CommunityPageSubCollectionList Component', () => { findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { name: 'test', values: [ - 'org.dspace.ctask.general.ProfileFormats = test' - ] - })) + 'org.dspace.ctask.general.ProfileFormats = test', + ], + })), }); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - SharedModule, RouterTestingModule.withRoutes([]), NgbModule, - NoopAnimationsModule + NoopAnimationsModule, + CommunityPageSubCollectionListComponent, ], - declarations: [CommunityPageSubCollectionListComponent], providers: [ { provide: CollectionDataService, useValue: collectionDataServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, @@ -166,7 +170,7 @@ describe('CommunityPageSubCollectionList Component', () => { { provide: ConfigurationDataService, useValue: configurationDataService }, { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -177,19 +181,19 @@ describe('CommunityPageSubCollectionList Component', () => { }); - it('should display a list of collections', () => { - waitForAsync(() => { - subCollList = collections; - fixture.detectChanges(); + it('should display a list of collections', async () => { + subCollList = collections; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); - const collList = fixture.debugElement.queryAll(By.css('li')); - expect(collList.length).toEqual(5); - expect(collList[0].nativeElement.textContent).toContain('Collection 1'); - expect(collList[1].nativeElement.textContent).toContain('Collection 2'); - expect(collList[2].nativeElement.textContent).toContain('Collection 3'); - expect(collList[3].nativeElement.textContent).toContain('Collection 4'); - expect(collList[4].nativeElement.textContent).toContain('Collection 5'); - }); + const collList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(collList.length).toEqual(5); + expect(collList[0].nativeElement.textContent).toContain('Collection 1'); + expect(collList[1].nativeElement.textContent).toContain('Collection 2'); + expect(collList[2].nativeElement.textContent).toContain('Collection 3'); + expect(collList[3].nativeElement.textContent).toContain('Collection 4'); + expect(collList[4].nativeElement.textContent).toContain('Collection 5'); }); it('should not display the header when list of collections is empty', () => { diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts index 92e689b127..1e8ff1d46c 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,23 +1,55 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Subscription, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + SortDirection, + SortOptions, +} from '../../../../core/cache/models/sort-options.model'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { Collection } from '../../../../core/shared/collection.model'; import { Community } from '../../../../core/shared/community.model'; import { fadeIn } from '../../../../shared/animations/fade'; -import { PaginatedList } from '../../../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; -import { CollectionDataService } from '../../../../core/data/collection-data.service'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; -import { switchMap } from 'rxjs/operators'; import { hasValue } from '../../../../shared/empty.util'; +import { ErrorComponent } from '../../../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../shared/object-collection/object-collection.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { VarDirective } from '../../../../shared/utils/var.directive'; @Component({ - selector: 'ds-community-page-sub-collection-list', + selector: 'ds-base-community-page-sub-collection-list', styleUrls: ['./community-page-sub-collection-list.component.scss'], templateUrl: './community-page-sub-collection-list.component.html', - animations:[fadeIn] + animations: [fadeIn], + imports: [ + ObjectCollectionComponent, + ErrorComponent, + ThemedLoadingComponent, + NgIf, + TranslateModule, + AsyncPipe, + VarDirective, + ], + standalone: true, }) export class CommunityPageSubCollectionListComponent implements OnInit, OnDestroy { @Input() community: Community; @@ -74,17 +106,17 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro * Initialise the list of collections */ initPage() { - const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); - const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); + const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); + const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( switchMap(([currentPagination, currentSort]) => { return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, elementsPerPage: currentPagination.pageSize, - sort: {field: currentSort.field, direction: currentSort.direction} + sort: { field: currentSort.field, direction: currentSort.direction }, }); - }) + }), ).subscribe((results) => { this.subCollectionsRDObs.next(results); })); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts index ebbec33e8e..4a965bc926 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts @@ -1,12 +1,18 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { Community } from '../../../../core/shared/community.model'; import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -import { Component, Input } from '@angular/core'; -import { Community } from '../../../../core/shared/community.model'; @Component({ - selector: 'ds-themed-community-page-sub-collection-list', + selector: 'ds-community-page-sub-collection-list', styleUrls: [], templateUrl: '../../../../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityPageSubCollectionListComponent], }) export class ThemedCollectionPageSubCollectionListComponent extends ThemedComponent { @Input() community: Community; diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html index 515e08ffdf..a811014bcc 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html @@ -1,8 +1,8 @@ - - - + - + diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts index 804299d3d9..85d8eb4fb7 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts @@ -1,7 +1,11 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SubComColSectionComponent } from './sub-com-col-section.component'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; + import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { SubComColSectionComponent } from './sub-com-col-section.component'; describe('SubComColSectionComponent', () => { let component: SubComColSectionComponent; @@ -11,11 +15,10 @@ describe('SubComColSectionComponent', () => { beforeEach(async () => { activatedRoute = new ActivatedRouteStub(); + activatedRoute.parent = new ActivatedRouteStub(); await TestBed.configureTestingModule({ - declarations: [ - SubComColSectionComponent, - ], + imports: [SubComColSectionComponent], providers: [ { provide: ActivatedRoute, useValue: activatedRoute }, ], diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts index ff30e51607..7aed3be076 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts @@ -1,14 +1,34 @@ -import { Component, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Data, +} from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { Community } from '../../../core/shared/community.model'; -import { ActivatedRoute, Data } from '@angular/router'; -import { map } from 'rxjs/operators'; +import { ThemedCollectionPageSubCollectionListComponent } from './sub-collection-list/themed-community-page-sub-collection-list.component'; +import { ThemedCommunityPageSubCommunityListComponent } from './sub-community-list/themed-community-page-sub-community-list.component'; @Component({ selector: 'ds-sub-com-col-section', templateUrl: './sub-com-col-section.component.html', styleUrls: ['./sub-com-col-section.component.scss'], + imports: [ + ThemedCommunityPageSubCommunityListComponent, + ThemedCollectionPageSubCollectionListComponent, + AsyncPipe, + NgIf, + ], + standalone: true, }) export class SubComColSectionComponent implements OnInit { @@ -20,7 +40,7 @@ export class SubComColSectionComponent implements OnInit { } ngOnInit(): void { - this.community$ = this.route.data.pipe( + this.community$ = this.route.parent.data.pipe( map((data: Data) => (data.dso as RemoteData).payload), ); } diff --git a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html index 0834d08ba5..7f9840f6b7 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html @@ -9,5 +9,5 @@ - + diff --git a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts index c5efc9c2c1..2654585eda 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -1,36 +1,41 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; - +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; -import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -import { Community } from '../../../../core/shared/community.model'; -import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; -import { PageInfo } from '../../../../core/shared/page-info.model'; -import { SharedModule } from '../../../../shared/shared.module'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { HostWindowService } from '../../../../shared/host-window.service'; -import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; import { CommunityDataService } from '../../../../core/data/community-data.service'; -import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; -import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; -import { ThemeService } from '../../../../shared/theme-support/theme.service'; -import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../../core/data/find-list-options.model'; -import { GroupDataService } from '../../../../core/eperson/group-data.service'; -import { LinkHeadService } from '../../../../core/services/link-head.service'; import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; -import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; -import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { LinkHeadService } from '../../../../core/services/link-head.service'; +import { Community } from '../../../../core/shared/community.model'; import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { HostWindowService } from '../../../../shared/host-window.service'; +import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; +import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { ThemeService } from '../../../../shared/theme-support/theme.service'; +import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -describe('CommunityPageSubCommunityListComponent Component', () => { +describe('CommunityPageSubCommunityListComponent', () => { let comp: CommunityPageSubCommunityListComponent; let fixture: ComponentFixture; let communityDataServiceStub: any; @@ -41,67 +46,67 @@ describe('CommunityPageSubCommunityListComponent Component', () => { id: '123456789-1', metadata: { 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 1' } - ] - } + { language: 'en_US', value: 'SubCommunity 1' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 2' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-3', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 3' }, + ], + }, + }), + Object.assign(new Community(), { + id: '12345678942', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 4' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-5', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 5' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-6', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 6' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-7', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 7' }, + ], + }, }), - Object.assign(new Community(), { - id: '123456789-2', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 2' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-3', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 3' } - ] - } - }), - Object.assign(new Community(), { - id: '12345678942', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 4' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-5', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 5' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-6', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 6' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-7', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 7' } - ] - } - }) ]; const mockCommunity = Object.assign(new Community(), { id: '123456789', metadata: { 'dc.title': [ - { language: 'en_US', value: 'Test title' } - ] - } + { language: 'en_US', value: 'Test title' }, + ], + }, }); communityDataServiceStub = { @@ -120,11 +125,11 @@ describe('CommunityPageSubCommunityListComponent Component', () => { } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCommList.slice(startPageIndex, endPageIndex))); - } + }, }; const linkHeadService = jasmine.createSpyObj('linkHeadService', { - addTag: '' + addTag: '', }); const groupDataService = jasmine.createSpyObj('groupsDataService', { @@ -137,9 +142,9 @@ describe('CommunityPageSubCommunityListComponent Component', () => { findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { name: 'test', values: [ - 'org.dspace.ctask.general.ProfileFormats = test' - ] - })) + 'org.dspace.ctask.general.ProfileFormats = test', + ], + })), }); const paginationService = new PaginationServiceStub(); @@ -150,12 +155,11 @@ describe('CommunityPageSubCommunityListComponent Component', () => { TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - SharedModule, RouterTestingModule.withRoutes([]), NgbModule, - NoopAnimationsModule + NoopAnimationsModule, + CommunityPageSubCommunityListComponent, ], - declarations: [CommunityPageSubCommunityListComponent], providers: [ { provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, @@ -167,7 +171,7 @@ describe('CommunityPageSubCommunityListComponent Component', () => { { provide: ConfigurationDataService, useValue: configurationDataService }, { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -179,19 +183,19 @@ describe('CommunityPageSubCommunityListComponent Component', () => { }); - it('should display a list of sub-communities', () => { - waitForAsync(() => { - subCommList = subcommunities; - fixture.detectChanges(); + it('should display a list of sub-communities', async () => { + subCommList = subcommunities; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); - const subComList = fixture.debugElement.queryAll(By.css('li')); - expect(subComList.length).toEqual(5); - expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); - expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); - expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); - expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); - expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); - }); + const subComList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(subComList.length).toEqual(5); + expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); + expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); + expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); + expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); }); it('should not display the header when list of sub-communities is empty', () => { diff --git a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts index 3108be8a60..36bd9919bb 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts @@ -1,24 +1,58 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Subscription, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; - +import { + SortDirection, + SortOptions, +} from '../../../../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { Community } from '../../../../core/shared/community.model'; import { fadeIn } from '../../../../shared/animations/fade'; -import { PaginatedList } from '../../../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; -import { CommunityDataService } from '../../../../core/data/community-data.service'; -import { switchMap } from 'rxjs/operators'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; import { hasValue } from '../../../../shared/empty.util'; +import { ErrorComponent } from '../../../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../shared/object-collection/object-collection.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { VarDirective } from '../../../../shared/utils/var.directive'; @Component({ - selector: 'ds-community-page-sub-community-list', + selector: 'ds-base-community-page-sub-community-list', styleUrls: ['./community-page-sub-community-list.component.scss'], templateUrl: './community-page-sub-community-list.component.html', - animations: [fadeIn] + animations: [fadeIn], + imports: [ + ErrorComponent, + ThemedLoadingComponent, + VarDirective, + NgIf, + ObjectCollectionComponent, + AsyncPipe, + TranslateModule, + ObjectCollectionComponent, + ErrorComponent, + ThemedLoadingComponent, + VarDirective, + ], + standalone: true, }) /** * Component to render the sub-communities of a Community @@ -86,9 +120,9 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, elementsPerPage: currentPagination.pageSize, - sort: { field: currentSort.field, direction: currentSort.direction } + sort: { field: currentSort.field, direction: currentSort.direction }, }); - }) + }), ).subscribe((results) => { this.subCommunitiesRDObs.next(results); })); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts index 9c500cac10..5988ad0f5e 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts @@ -1,12 +1,18 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { Community } from '../../../../core/shared/community.model'; import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -import { Component, Input } from '@angular/core'; -import { Community } from '../../../../core/shared/community.model'; @Component({ - selector: 'ds-themed-community-page-sub-community-list', + selector: 'ds-community-page-sub-community-list', styleUrls: [], templateUrl: '../../../../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityPageSubCommunityListComponent], }) export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponent { diff --git a/src/app/community-page/themed-community-page.component.ts b/src/app/community-page/themed-community-page.component.ts index eeb058fb04..b655452041 100644 --- a/src/app/community-page/themed-community-page.component.ts +++ b/src/app/community-page/themed-community-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CommunityPageComponent } from './community-page.component'; @@ -6,9 +7,11 @@ import { CommunityPageComponent } from './community-page.component'; * Themed wrapper for CommunityPageComponent */ @Component({ - selector: 'ds-themed-community-page', + selector: 'ds-community-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityPageComponent], }) export class ThemedCommunityPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts index 3747edd532..295e5b1e75 100644 --- a/src/app/core/auth/auth-blocking.guard.spec.ts +++ b/src/app/core/auth/auth-blocking.guard.spec.ts @@ -1,15 +1,26 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { Store, StoreModule } from '@ngrx/store'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { cold } from 'jasmine-marbles'; -import { AppState, storeModuleConfig } from '../../app.reducer'; -import { AuthBlockingGuard } from './auth-blocking.guard'; +import { + AppState, + storeModuleConfig, +} from '../../app.reducer'; import { authReducer } from './auth.reducer'; +import { authBlockingGuard } from './auth-blocking.guard'; -describe('AuthBlockingGuard', () => { - let guard: AuthBlockingGuard; +describe('authBlockingGuard', () => { + let guard: any; let initialState; let store: Store; let mockStore: MockStore; @@ -21,9 +32,9 @@ describe('AuthBlockingGuard', () => { loaded: false, blocking: undefined, loading: false, - authMethods: [] - } - } + authMethods: [], + }, + }, }; beforeEach(waitForAsync(() => { @@ -33,22 +44,22 @@ describe('AuthBlockingGuard', () => { ], providers: [ provideMockStore({ initialState }), - { provide: AuthBlockingGuard, useValue: guard } - ] + { provide: authBlockingGuard, useValue: guard }, + ], }).compileComponents(); })); beforeEach(() => { store = TestBed.inject(Store); mockStore = store as MockStore; - guard = new AuthBlockingGuard(store); + guard = authBlockingGuard; }); describe(`canActivate`, () => { describe(`when authState.blocking is undefined`, () => { it(`should not emit anything`, (done) => { - expect(guard.canActivate()).toBeObservable(cold('-')); + expect(guard(null, null, store)).toBeObservable(cold('-')); done(); }); }); @@ -58,15 +69,15 @@ describe('AuthBlockingGuard', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'auth': { - blocking: true - } - }) + blocking: true, + }, + }), }); mockStore.setState(state); }); it(`should not emit anything`, (done) => { - expect(guard.canActivate()).toBeObservable(cold('-')); + expect(guard(null, null, store)).toBeObservable(cold('-')); done(); }); }); @@ -76,15 +87,15 @@ describe('AuthBlockingGuard', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'auth': { - blocking: false - } - }) + blocking: false, + }, + }), }); mockStore.setState(state); }); it(`should succeed`, (done) => { - expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true })); + expect(guard(null, null, store)).toBeObservable(cold('(a|)', { a: true })); done(); }); }); diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts index 9054f66f8b..c76480ec0d 100644 --- a/src/app/core/auth/auth-blocking.guard.ts +++ b/src/app/core/auth/auth-blocking.guard.ts @@ -1,8 +1,21 @@ -import { Injectable } from '@angular/core'; -import { CanActivate } from '@angular/router'; -import { select, Store } from '@ngrx/store'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + RouterStateSnapshot, +} from '@angular/router'; +import { + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, + take, +} from 'rxjs/operators'; + import { AppState } from '../../app.reducer'; import { isAuthenticationBlocking } from './selectors'; @@ -11,24 +24,16 @@ import { isAuthenticationBlocking } from './selectors'; * route until the authentication status has loaded. * To ensure all rest requests get the correct auth header. */ -@Injectable({ - providedIn: 'root' -}) -export class AuthBlockingGuard implements CanActivate { +export const authBlockingGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + store: Store = inject(Store), +): Observable => { + return store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged(), + filter((finished: boolean) => finished === true), + take(1), + ); +}; - constructor(private store: Store) { - } - - /** - * True when the authentication isn't blocking everything - */ - canActivate(): Observable { - return this.store.pipe(select(isAuthenticationBlocking)).pipe( - map((isBlocking: boolean) => isBlocking === false), - distinctUntilChanged(), - filter((finished: boolean) => finished === true), - take(1), - ); - } - -} diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts index 063aad612f..2220efe5fa 100644 --- a/src/app/core/auth/auth-request.service.spec.ts +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -1,17 +1,21 @@ -import { AuthRequestService } from './auth-request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { PostRequest } from '../data/request.models'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { ShortLivedToken } from './models/short-lived-token.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import objectContaining = jasmine.objectContaining; -import { AuthStatus } from './models/auth-status.model'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; import { RestRequestMethod } from '../data/rest-request-method'; -import { Observable, of as observableOf } from 'rxjs'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthRequestService } from './auth-request.service'; +import { AuthStatus } from './models/auth-status.model'; +import { ShortLivedToken } from './models/short-lived-token.model'; +import objectContaining = jasmine.objectContaining; describe(`AuthRequestService`, () => { let halService: HALEndpointService; @@ -30,7 +34,7 @@ describe(`AuthRequestService`, () => { constructor( hes: HALEndpointService, rs: RequestService, - rdbs: RemoteDataBuildService + rdbs: RemoteDataBuildService, ) { super(hes, rs, rdbs); } @@ -44,19 +48,19 @@ describe(`AuthRequestService`, () => { endpointURL = 'https://rest.api/auth'; requestID = 'requestID'; shortLivedToken = Object.assign(new ShortLivedToken(), { - value: 'some-token' + value: 'some-token', }); shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); halService = jasmine.createSpyObj('halService', { - 'getEndpoint': cold('a', { a: endpointURL }) + 'getEndpoint': cold('a', { a: endpointURL }), }); requestService = jasmine.createSpyObj('requestService', { 'generateRequestId': requestID, 'send': null, }); rdbService = jasmine.createSpyObj('rdbService', { - 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) + 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }), }); service = new TestAuthRequestService(halService, requestService, rdbService); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 7c1f17dec2..5d11b9f4cb 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,18 +1,29 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, tap, take } from 'rxjs/operators'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + import { isNotEmpty } from '../../shared/empty.util'; -import { GetRequest, PostRequest, } from '../data/request.models'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; +import { + GetRequest, + PostRequest, +} from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequest } from '../data/rest-request.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { RestRequest } from '../data/rest-request.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * Abstract service to send authentication requests @@ -23,8 +34,8 @@ export abstract class AuthRequestService { constructor(protected halService: HALEndpointService, protected requestService: RequestService, - private rdbService: RemoteDataBuildService - ) { + private rdbService: RemoteDataBuildService, + ) { } /** @@ -65,7 +76,7 @@ export abstract class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)), - take(1) + take(1), ).subscribe((request: PostRequest) => { this.requestService.send(request); }); @@ -90,7 +101,7 @@ export abstract class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), distinctUntilChanged(), map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)), - take(1) + take(1), ).subscribe((request: GetRequest) => { this.requestService.send(request); }); @@ -125,7 +136,7 @@ export abstract class AuthRequestService { } else { return null; } - }) + }), ); } } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 6bc4565682..03b6bc1191 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,12 +1,13 @@ /* eslint-disable max-classes-per-file */ // import @ngrx import { Action } from '@ngrx/store'; + // import type function import { type } from '../../shared/ngrx/type'; -// import models -import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; +// import models +import { AuthTokenInfo } from './models/auth-token-info.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -38,7 +39,7 @@ export const AuthActionTypes = { RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS'), SET_USER_AS_IDLE: type('dspace/auth/SET_USER_AS_IDLE'), - UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE') + UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE'), }; @@ -426,6 +427,15 @@ export class UnsetUserAsIdleAction implements Action { public type: string = AuthActionTypes.UNSET_USER_AS_IDLE; } +/** + * Authentication error actions that include Error payloads. + */ +export type AuthErrorActionsWithErrorPayload + = AuthenticatedErrorAction + | AuthenticationErrorAction + | LogOutErrorAction + | RetrieveAuthenticatedEpersonErrorAction; + /** * Actions type. * @type {AuthActions} @@ -433,9 +443,7 @@ export class UnsetUserAsIdleAction implements Action { export type AuthActions = AuthenticateAction | AuthenticatedAction - | AuthenticatedErrorAction | AuthenticatedSuccessAction - | AuthenticationErrorAction | AuthenticationSuccessAction | CheckAuthenticationTokenAction | CheckAuthenticationTokenCookieAction @@ -452,10 +460,9 @@ export type AuthActions | RetrieveAuthMethodsErrorAction | RetrieveTokenAction | RetrieveAuthenticatedEpersonAction - | RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonSuccessAction | SetRedirectUrlAction | RedirectAfterLoginSuccessAction | SetUserAsIdleAction - | UnsetUserAsIdleAction; - + | UnsetUserAsIdleAction + | AuthErrorActionsWithErrorPayload; diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 2e6ba917aa..a423455594 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -1,12 +1,38 @@ -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; - +import { + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, + throwError as observableThrow, +} from 'rxjs'; -import { AuthEffects } from './auth.effects'; +import { + AppState, + storeModuleConfig, +} from '../../app.reducer'; +import { + authMethodsMock, + AuthServiceStub, +} from '../../shared/testing/auth-service.stub'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { StoreActionTypes } from '../../store.actions'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { AuthActionTypes, AuthenticatedAction, @@ -25,17 +51,16 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, - RetrieveTokenAction + RetrieveTokenAction, } from './auth.actions'; -import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub'; -import { AuthService } from './auth.service'; +import { AuthEffects } from './auth.effects'; import { authReducer } from './auth.reducer'; +import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AppState, storeModuleConfig } from '../../app.reducer'; -import { StoreActionTypes } from '../../store.actions'; -import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { + isAuthenticated, + isAuthenticatedLoaded, +} from './selectors'; describe('AuthEffects', () => { let authEffects: AuthEffects; @@ -56,9 +81,9 @@ describe('AuthEffects', () => { authenticated: false, loaded: false, loading: false, - authMethods: [] - } - } + authMethods: [], + }, + }, }; } @@ -66,7 +91,7 @@ describe('AuthEffects', () => { init(); TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({ auth: authReducer }, storeModuleConfig) + StoreModule.forRoot({ auth: authReducer }, storeModuleConfig), ], providers: [ AuthEffects, @@ -88,8 +113,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE, - payload: { email: 'user', password: 'password' } - } + payload: { email: 'user', password: 'password' }, + }, }); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); @@ -105,8 +130,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE, - payload: { email: 'user', password: 'wrongpassword' } - } + payload: { email: 'user', password: 'wrongpassword' }, + }, }); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); @@ -161,9 +186,9 @@ describe('AuthEffects', () => { type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: { authenticated: true, authToken: token, - userHref: EPersonMock._links.self.href - } - } + userHref: EPersonMock._links.self.href, + }, + }, }); const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) }); @@ -211,8 +236,8 @@ describe('AuthEffects', () => { spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( observableOf( { - authenticated: true - }) + authenticated: true, + }), ); spyOn((authEffects as any).authService, 'setExternalAuthStatus'); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); @@ -230,7 +255,7 @@ describe('AuthEffects', () => { it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( observableOf( - { authenticated: false }) + { authenticated: false }), ); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); @@ -260,8 +285,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON, - payload: EPersonMock._links.self.href - } + payload: EPersonMock._links.self.href, + }, }); const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id) }); @@ -314,8 +339,8 @@ describe('AuthEffects', () => { it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => { actions = hot('--a-', { a: { - type: AuthActionTypes.RETRIEVE_TOKEN - } + type: AuthActionTypes.RETRIEVE_TOKEN, + }, }); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); @@ -330,8 +355,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { - type: AuthActionTypes.RETRIEVE_TOKEN - } + type: AuthActionTypes.RETRIEVE_TOKEN, + }, }); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 281355b769..2919a40fa8 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,27 +1,47 @@ -import { Injectable, NgZone } from '@angular/core'; - +import { + Injectable, + NgZone, + Type, +} from '@angular/core'; +// import @ngrx +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + select, + Store, +} from '@ngrx/store'; import { asyncScheduler, combineLatest as observableCombineLatest, Observable, of as observableOf, queueScheduler, - timer + timer, } from 'rxjs'; -import { catchError, filter, map, observeOn, switchMap, take, tap } from 'rxjs/operators'; -// import @ngrx -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Action, select, Store } from '@ngrx/store'; +import { + catchError, + filter, + map, + observeOn, + switchMap, + take, + tap, +} from 'rxjs/operators'; -// import services -import { AuthService } from './auth.service'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; +import { environment } from '../../../environments/environment'; import { AppState } from '../../app.reducer'; -import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; import { StoreActionTypes } from '../../store.actions'; -import { AuthMethod } from './models/auth.method'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { RequestActionTypes } from '../data/request.actions'; +import { EPerson } from '../eperson/models/eperson.model'; +import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; +import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; // import actions import { AuthActionTypes, @@ -31,6 +51,7 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, + AuthErrorActionsWithErrorPayload, CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, @@ -45,20 +66,32 @@ import { RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, RetrieveTokenAction, - SetUserAsIdleAction + SetUserAsIdleAction, } from './auth.actions'; -import { hasValue } from '../../shared/empty.util'; -import { environment } from '../../../environments/environment'; -import { RequestActionTypes } from '../data/request.actions'; -import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; -import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; -import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +// import services +import { AuthService } from './auth.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { + isAuthenticated, + isAuthenticatedLoaded, +} from './selectors'; // Action Types that do not break/prevent the user from an idle state const IDLE_TIMER_IGNORE_TYPES: string[] = [...Object.values(AuthActionTypes).filter((t: string) => t !== AuthActionTypes.UNSET_USER_AS_IDLE), - ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; + ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; + +export function errorToAuthAction$(actionType: Type, error: unknown): Observable { + if (error instanceof Error) { + return observableOf(new actionType(error)); + } + + // If we caught something that's not an Error: complain & drop type safety + console.warn('AuthEffects caught non-Error object:', error); + return observableOf(new actionType(error as any)); +} @Injectable() export class AuthEffects { @@ -73,14 +106,14 @@ export class AuthEffects { return this.authService.authenticate(action.payload.email, action.payload.password).pipe( take(1), map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticationErrorAction, error)), ); - }) + }), )); public authenticateSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)), )); public authenticated$: Observable = createEffect(() => this.actions$.pipe( @@ -88,8 +121,9 @@ export class AuthEffects { switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); - }) + catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), + ); + }), )); public authenticatedSuccess$: Observable = createEffect(() => this.actions$.pipe( @@ -97,7 +131,7 @@ export class AuthEffects { tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( take(1), - map((redirectUrl: string) => [action, redirectUrl]) + map((redirectUrl: string) => [action, redirectUrl]), )), map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => { if (hasValue(redirectUrl)) { @@ -105,7 +139,7 @@ export class AuthEffects { } else { return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); } - }) + }), )); public redirectAfterLoginSuccess$: Observable = createEffect(() => this.actions$.pipe( @@ -113,13 +147,13 @@ export class AuthEffects { tap((action: RedirectAfterLoginSuccessAction) => { this.authService.clearRedirectUrl(); this.authService.navigateToRedirectUrl(action.payload); - }) + }), ), { dispatch: false }); // It means "reacts to this action but don't send another" public authenticatedError$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_ERROR), - tap((action: LogOutSuccessAction) => this.authService.removeToken()) + tap((action: LogOutSuccessAction) => this.authService.removeToken()), ), { dispatch: false }); public retrieveAuthenticatedEperson$: Observable = createEffect(() => this.actions$.pipe( @@ -134,17 +168,18 @@ export class AuthEffects { } return user$.pipe( map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), - catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); - }) + catchError((error: unknown) => errorToAuthAction$(RetrieveAuthenticatedEpersonErrorAction, error)), + ); + }), )); public checkToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), switchMap(() => { return this.authService.hasValidAuthenticationToken().pipe( map((token: AuthTokenInfo) => new AuthenticatedAction(token)), - catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) + catchError((error: unknown) => observableOf(new CheckAuthenticationTokenCookieAction())), ); - }) + }), )); public checkTokenCookie$: Observable = createEffect(() => this.actions$.pipe( @@ -160,9 +195,9 @@ export class AuthEffects { return new RetrieveAuthMethodsAction(response); } }), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), ); - }) + }), )); public retrieveToken$: Observable = createEffect(() => this.actions$.pipe( @@ -171,24 +206,24 @@ export class AuthEffects { return this.authService.refreshAuthenticationToken(null).pipe( take(1), map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticationErrorAction, error)), ); - }) + }), )); public refreshToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), switchMap((action: RefreshTokenAction) => { return this.authService.refreshAuthenticationToken(action.payload).pipe( map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), - catchError((error) => observableOf(new RefreshTokenErrorAction())) + catchError((error: unknown) => observableOf(new RefreshTokenErrorAction())), ); - }) + }), )); // It means "reacts to this action but don't send another" public refreshTokenSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), - tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)), ), { dispatch: false }); /** @@ -204,7 +239,7 @@ export class AuthEffects { take(1), filter(([loaded, authenticated]) => loaded && !authenticated), tap(() => this.authService.removeToken()), - tap(() => this.authService.resetAuthenticationError()) + tap(() => this.authService.resetAuthenticationError()), ); })), { dispatch: false }); @@ -213,9 +248,9 @@ export class AuthEffects { * authorizations endpoint, to be sure to have consistent responses after a login with external idp * */ - invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ + invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), - tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()) + tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()), ), { dispatch: false }); public logOut$: Observable = createEffect(() => this.actions$ @@ -224,24 +259,24 @@ export class AuthEffects { switchMap(() => { this.authService.stopImpersonating(); return this.authService.logout().pipe( - map((value) => new LogOutSuccessAction()), - catchError((error) => observableOf(new LogOutErrorAction(error))) + map(() => new LogOutSuccessAction()), + catchError((error: unknown) => errorToAuthAction$(LogOutErrorAction, error)), ); - }) + }), )); public logOutSuccess$: Observable = createEffect(() => this.actions$ .pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS), tap(() => this.authService.removeToken()), tap(() => this.authService.clearRedirectUrl()), - tap(() => this.authService.refreshAfterLogout()) + tap(() => this.authService.refreshAfterLogout()), ), { dispatch: false }); public redirectToLoginTokenExpired$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED), tap(() => this.authService.removeToken()), - tap(() => this.authService.redirectToLoginWhenTokenExpired()) + tap(() => this.authService.redirectToLoginWhenTokenExpired()), ), { dispatch: false }); public retrieveMethods$: Observable = createEffect(() => this.actions$ @@ -251,9 +286,9 @@ export class AuthEffects { return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) .pipe( map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), - catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + catchError(() => observableOf(new RetrieveAuthMethodsErrorAction())), ); - }) + }), )); /** @@ -268,7 +303,7 @@ export class AuthEffects { // in, and start a new timer switchMap(() => // Start a timer outside of Angular's zone - timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)) + timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)), ), // Re-enter the zone to dispatch the action observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 04bbc4acaf..d824df472a 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -1,18 +1,20 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; - import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; -import { AuthInterceptor } from './auth.interceptor'; -import { AuthService } from './auth.service'; -import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RouterStub } from '../../shared/testing/router.stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; -import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RestRequestMethod } from '../data/rest-request-method'; +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { AuthInterceptor } from './auth.interceptor'; +import { AuthService } from './auth.service'; describe(`AuthInterceptor`, () => { let service: DspaceRestService; @@ -20,10 +22,8 @@ describe(`AuthInterceptor`, () => { const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - select: observableOf(true) + select: observableOf(true), }); beforeEach(() => { @@ -46,6 +46,10 @@ describe(`AuthInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); }); + afterEach(() => { + httpMock.verify(); + }); + describe('when has a valid token', () => { it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint that is not the logout endpoint', () => { @@ -95,14 +99,11 @@ describe(`AuthInterceptor`, () => { }); it('should redirect to login', () => { - - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { - expect(response).toBeTruthy(); - }); - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); + // HttpTestingController.expectNone will throw an error when a requests is made + expect().nothing(); }); }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 4ad856fd88..84d4c53210 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,7 +1,3 @@ -import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; - -import { catchError, map } from 'rxjs/operators'; -import { Injectable, Injector } from '@angular/core'; import { HttpErrorResponse, HttpEvent, @@ -10,19 +6,36 @@ import { HttpInterceptor, HttpRequest, HttpResponse, - HttpResponseBase + HttpResponseBase, } from '@angular/common/http'; +import { + Injectable, + Injector, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { + Observable, + of as observableOf, + throwError as observableThrowError, +} from 'rxjs'; +import { + catchError, + map, +} from 'rxjs/operators'; import { AppState } from '../../app.reducer'; -import { AuthService } from './auth.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, + isNotNull, +} from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction } from './auth.actions'; -import { Store } from '@ngrx/store'; -import { Router } from '@angular/router'; +import { AuthService } from './auth.service'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -207,8 +220,8 @@ export class AuthInterceptor implements HttpInterceptor { message: 'Unknown auth error', status: 500, timestamp: Date.now(), - path: '' - }; + path: '', + }; } } else { authStatus.error = error; @@ -261,7 +274,7 @@ export class AuthInterceptor implements HttpInterceptor { // login successfully const newToken = response.headers.get('authorization'); authRes = response.clone({ - body: this.makeAuthStatusObject(true, newToken) + body: this.makeAuthStatusObject(true, newToken), }); // clean eventually refresh Requests list @@ -269,13 +282,13 @@ export class AuthInterceptor implements HttpInterceptor { } else if (this.isStatusResponse(response)) { authRes = response.clone({ body: Object.assign(response.body, { - authMethods: this.parseAuthMethodsFromHeaders(response.headers) - }) + authMethods: this.parseAuthMethodsFromHeaders(response.headers), + }), }); } else { // logout successfully authRes = response.clone({ - body: this.makeAuthStatusObject(false) + body: this.makeAuthStatusObject(false), }); } return authRes; @@ -283,7 +296,7 @@ export class AuthInterceptor implements HttpInterceptor { return response; } }), - catchError((error, caught) => { + catchError((error: unknown, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { @@ -298,7 +311,7 @@ export class AuthInterceptor implements HttpInterceptor { headers: error.headers, status: error.status, statusText: error.statusText, - url: error.url + url: error.url, }); return observableOf(authResponse); } else if (this.isUnauthorized(error) && isNotNull(token) && authService.isTokenExpired()) { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 41c0312653..7860744aa5 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -1,4 +1,4 @@ -import { authReducer, AuthState } from './auth.reducer'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; import { AddAuthenticationMessageAction, AuthenticateAction, @@ -8,7 +8,6 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, - SetAuthCookieStatus, CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, @@ -24,15 +23,19 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, + SetAuthCookieStatus, SetRedirectUrlAction, SetUserAsIdleAction, - UnsetUserAsIdleAction + UnsetUserAsIdleAction, } from './auth.actions'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AuthStatus } from './models/auth-status.model'; +import { + authReducer, + AuthState, +} from './auth.reducer'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; describe('authReducer', () => { @@ -47,7 +50,7 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: false, - idle: false + idle: false, }; const action = new AuthenticateAction('user', 'password'); const newState = authReducer(initialState, action); @@ -58,7 +61,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); @@ -72,7 +75,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticationSuccessAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -88,7 +91,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticationErrorAction(mockError); const newState = authReducer(initialState, action); @@ -100,7 +103,7 @@ describe('authReducer', () => { info: undefined, authToken: undefined, error: 'Test error message', - idle: false + idle: false, }; expect(newState).toEqual(state); @@ -114,7 +117,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -125,7 +128,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -138,7 +141,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href); const newState = authReducer(initialState, action); @@ -150,7 +153,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -163,7 +166,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedErrorAction(mockError); const newState = authReducer(initialState, action); @@ -175,7 +178,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -186,7 +189,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new CheckAuthenticationTokenAction(); const newState = authReducer(initialState, action); @@ -195,7 +198,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -206,7 +209,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); @@ -215,7 +218,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -227,7 +230,7 @@ describe('authReducer', () => { blocking: false, loading: true, externalAuth: false, - idle: false + idle: false, }; const action = new SetAuthCookieStatus(true); const newState = authReducer(initialState, action); @@ -237,7 +240,7 @@ describe('authReducer', () => { blocking: false, loading: true, externalAuth: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -252,7 +255,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutAction(); @@ -271,7 +274,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutSuccessAction(); @@ -286,7 +289,7 @@ describe('authReducer', () => { info: undefined, refreshing: false, userId: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -301,7 +304,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutErrorAction(mockError); @@ -315,7 +318,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -329,7 +332,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const newState = authReducer(initialState, action); @@ -342,7 +345,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -355,7 +358,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new RetrieveAuthenticatedEpersonErrorAction(mockError); const newState = authReducer(initialState, action); @@ -367,7 +370,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -382,7 +385,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -397,7 +400,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -413,7 +416,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenSuccessAction(newTokenInfo); @@ -428,7 +431,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: false, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -444,7 +447,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; const action = new RefreshTokenErrorAction(); const newState = authReducer(initialState, action); @@ -458,7 +461,7 @@ describe('authReducer', () => { info: undefined, refreshing: false, userId: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -473,7 +476,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; state = { @@ -485,7 +488,7 @@ describe('authReducer', () => { error: undefined, info: 'Message', userId: undefined, - idle: false + idle: false, }; }); @@ -507,7 +510,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new AddAuthenticationMessageAction('Message'); const newState = authReducer(initialState, action); @@ -517,7 +520,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: 'Message', - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -530,7 +533,7 @@ describe('authReducer', () => { loading: false, error: 'Error', info: 'Message', - idle: false + idle: false, }; const action = new ResetAuthenticationMessagesAction(); const newState = authReducer(initialState, action); @@ -541,7 +544,7 @@ describe('authReducer', () => { loading: false, error: undefined, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -552,7 +555,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new SetRedirectUrlAction('redirect.url'); const newState = authReducer(initialState, action); @@ -562,7 +565,7 @@ describe('authReducer', () => { blocking: false, loading: false, redirectUrl: 'redirect.url', - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -574,7 +577,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: [], - idle: false + idle: false, }; const action = new RetrieveAuthMethodsAction(new AuthStatus()); const newState = authReducer(initialState, action); @@ -584,7 +587,7 @@ describe('authReducer', () => { blocking: false, loading: true, authMethods: [], - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -596,7 +599,7 @@ describe('authReducer', () => { blocking: true, loading: true, authMethods: [], - idle: false + idle: false, }; const authMethods: AuthMethod[] = [ new AuthMethod(AuthMethodType.Password, 0), @@ -610,7 +613,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: authMethods, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -622,7 +625,7 @@ describe('authReducer', () => { blocking: true, loading: true, authMethods: [], - idle: false + idle: false, }; const action = new RetrieveAuthMethodsErrorAction(); @@ -633,7 +636,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: [new AuthMethod(AuthMethodType.Password, 0)], - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -644,7 +647,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: false + idle: false, }; const action = new SetUserAsIdleAction(); @@ -654,7 +657,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: true + idle: true, }; expect(newState).toEqual(state); }); @@ -665,7 +668,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: true + idle: true, }; const action = new UnsetUserAsIdleAction(); @@ -675,7 +678,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: false + idle: false, }; expect(newState).toEqual(state); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 437c19fd26..8a399710ea 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -1,4 +1,5 @@ // import actions +import { StoreActionTypes } from '../../store.actions'; import { AddAuthenticationMessageAction, AuthActions, @@ -10,14 +11,14 @@ import { RedirectWhenTokenExpiredAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, - RetrieveAuthMethodsSuccessAction, SetAuthCookieStatus, - SetRedirectUrlAction + RetrieveAuthMethodsSuccessAction, + SetAuthCookieStatus, + SetRedirectUrlAction, } from './auth.actions'; -// import models -import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; -import { StoreActionTypes } from '../../store.actions'; +// import models +import { AuthTokenInfo } from './models/auth-token-info.model'; /** * The auth state. @@ -76,7 +77,7 @@ const initialState: AuthState = { loading: false, authMethods: [], externalAuth: false, - idle: false + idle: false, }; /** @@ -92,13 +93,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { error: undefined, loading: true, - info: undefined + info: undefined, }); case AuthActionTypes.AUTHENTICATED: return Object.assign({}, state, { loading: true, - blocking: true + blocking: true, }); case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: @@ -109,7 +110,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.SET_AUTH_COOKIE_STATUS: return Object.assign({}, state, { - externalAuth: (action as SetAuthCookieStatus).payload + externalAuth: (action as SetAuthCookieStatus).payload, }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -120,13 +121,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: (action as AuthenticationErrorAction).payload.message, loaded: true, blocking: false, - loading: false + loading: false, }); case AuthActionTypes.AUTHENTICATED_SUCCESS: return Object.assign({}, state, { authenticated: true, - authToken: (action as AuthenticatedSuccessAction).payload.authToken + authToken: (action as AuthenticatedSuccessAction).payload.authToken, }); case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: @@ -136,7 +137,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, blocking: false, info: undefined, - userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload + userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload, }); case AuthActionTypes.AUTHENTICATE_ERROR: @@ -145,7 +146,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, blocking: false, - loading: false + loading: false, }); case AuthActionTypes.AUTHENTICATE_SUCCESS: @@ -155,7 +156,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.LOG_OUT_ERROR: return Object.assign({}, state, { authenticated: true, - error: (action as LogOutErrorAction).payload.message + error: (action as LogOutErrorAction).payload.message, }); case AuthActionTypes.REFRESH_TOKEN_ERROR: @@ -168,7 +169,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, info: undefined, refreshing: false, - userId: undefined + userId: undefined, }); case AuthActionTypes.LOG_OUT_SUCCESS: @@ -181,7 +182,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: true, info: undefined, refreshing: false, - userId: undefined + userId: undefined, }); case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: @@ -193,7 +194,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut blocking: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, - userId: undefined + userId: undefined, }); case AuthActionTypes.REFRESH_TOKEN: @@ -205,7 +206,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authToken: (action as RefreshTokenSuccessAction).payload, refreshing: false, - blocking: false + blocking: false, }); case AuthActionTypes.ADD_MESSAGE: @@ -229,14 +230,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { loading: false, blocking: false, - authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload, }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: return Object.assign({}, state, { loading: false, blocking: false, - authMethods: [new AuthMethod(AuthMethodType.Password, 0)] + authMethods: [new AuthMethod(AuthMethodType.Password, 0)], }); case AuthActionTypes.SET_REDIRECT_URL: diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index b38d17aecd..66c80b9bf5 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -1,46 +1,75 @@ -import { inject, TestBed, waitForAsync } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Store, StoreModule } from '@ngrx/store'; -import { REQUEST } from '@nguniversal/express-engine/tokens'; -import { Observable, of as observableOf } from 'rxjs'; -import { authReducer, AuthState } from './auth.reducer'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { CookieService } from '../services/cookie.service'; -import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; -import { AuthRequestService } from './auth-request.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPerson } from '../eperson/models/eperson.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { REQUEST } from '../../../express.tokens'; import { AppState } from '../../app.reducer'; -import { ClientCookieService } from '../services/client-cookie.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; +import { authMethodsMock } from '../../shared/testing/auth-service.stub'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; -import { RouteService } from '../services/route.service'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { + SpecialGroupDataMock, + SpecialGroupDataMock$, +} from '../../shared/testing/special-group.mock'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { authMethodsMock } from '../../shared/testing/auth-service.stub'; -import { AuthMethod } from './models/auth.method'; +import { EPerson } from '../eperson/models/eperson.model'; +import { ClientCookieService } from '../services/client-cookie.service'; +import { CookieService } from '../services/cookie.service'; import { HardRedirectService } from '../services/hard-redirect.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; -import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock'; -import { cold } from 'jasmine-marbles'; +import { RouteService } from '../services/route.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + SetUserAsIdleAction, + UnsetUserAsIdleAction, +} from './auth.actions'; +import { + authReducer, + AuthState, +} from './auth.reducer'; +import { + AuthService, + IMPERSONATING_COOKIE, +} from './auth.service'; +import { AuthRequestService } from './auth-request.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; describe('AuthService test', () => { const mockEpersonDataService: any = { findByHref(href: string): Observable> { return createSuccessfulRemoteDataObject$(EPersonMock); - } + }, }; let mockStore: Store; @@ -62,13 +91,13 @@ describe('AuthService test', () => { uuid: 'test', authenticated: true, okay: true, - specialGroups: SpecialGroupDataMock$ + specialGroups: SpecialGroupDataMock$, }); function init() { mockStore = jasmine.createSpyObj('store', { dispatch: {}, - pipe: observableOf(true) + pipe: observableOf(true), }); window = new NativeWindowRef(); routerStub = new RouterStub(); @@ -80,7 +109,7 @@ describe('AuthService test', () => { loading: false, authToken: token, user: EPersonMock, - idle: false + idle: false, }; unAuthenticatedState = { authenticated: false, @@ -88,7 +117,7 @@ describe('AuthService test', () => { loading: false, authToken: undefined, user: undefined, - idle: false + idle: false, }; idleState = { authenticated: true, @@ -96,12 +125,12 @@ describe('AuthService test', () => { loading: false, authToken: token, user: EPersonMock, - idle: true + idle: true, }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); linkService = { - resolveLinks: {} + resolveLinks: {}, }; hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); @@ -117,11 +146,10 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } + strictActionImmutability: false, + }, }), ], - declarations: [], providers: [ { provide: AuthRequestService, useValue: authRequest }, { provide: NativeWindowService, useValue: window }, @@ -135,7 +163,7 @@ describe('AuthService test', () => { { provide: NotificationsService, useValue: NotificationsServiceStub }, { provide: TranslateService, useValue: getMockTranslateService() }, CookieService, - AuthService + AuthService, ], }); authService = TestBed.inject(AuthService); @@ -238,9 +266,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -249,8 +277,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -313,9 +341,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -325,8 +353,8 @@ describe('AuthService test', () => { { provide: RemoteDataBuildService, useValue: linkService }, ClientCookieService, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -338,7 +366,7 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: expiredToken, - user: EPersonMock + user: EPersonMock, }; store .subscribe((state) => { @@ -528,7 +556,7 @@ describe('AuthService test', () => { it('should call navigateToRedirectUrl with no url', () => { const expectRes = cold('(a|)', { - a: SpecialGroupDataMock + a: SpecialGroupDataMock, }); expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes); }); @@ -543,9 +571,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -554,8 +582,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -583,9 +611,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -594,8 +622,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 8b08b4f32d..cd773b68cf 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,18 +1,34 @@ -import { Inject, Injectable, Optional } from '@angular/core'; -import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; - -import { Observable, of as observableOf } from 'rxjs'; -import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; -import { select, Store } from '@ngrx/store'; +import { + Inject, + Injectable, + Optional, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + select, + Store, +} from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { CookieAttributes } from 'js-cookie'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + startWith, + switchMap, + take, +} from 'rxjs/operators'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthRequestService } from './auth-request.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; +import { environment } from '../../../environments/environment'; +import { + REQUEST, + RESPONSE, +} from '../../../express.tokens'; +import { AppState } from '../../app.reducer'; import { hasNoValue, hasValue, @@ -20,42 +36,58 @@ import { isEmpty, isNotEmpty, isNotNull, - isNotUndefined + isNotUndefined, } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { EPerson } from '../eperson/models/eperson.model'; +import { Group } from '../eperson/models/group.model'; import { CookieService } from '../services/cookie.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { RouteService } from '../services/route.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, +} from '../shared/operators'; +import { PageInfo } from '../shared/page-info.model'; +import { + CheckAuthenticationTokenAction, + RefreshTokenAction, + ResetAuthenticationMessagesAction, + SetAuthCookieStatus, + SetRedirectUrlAction, + SetUserAsIdleAction, + UnsetUserAsIdleAction, +} from './auth.actions'; +import { AuthRequestService } from './auth-request.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { + AuthTokenInfo, + TOKENITEM, +} from './models/auth-token-info.model'; import { getAuthenticatedUserId, - getAuthenticationToken, getExternalAuthCookieStatus, + getAuthenticationToken, + getExternalAuthCookieStatus, getRedirectUrl, isAuthenticated, isAuthenticatedLoaded, isIdle, - isTokenRefreshing + isTokenRefreshing, } from './selectors'; -import { AppState } from '../../app.reducer'; -import { - CheckAuthenticationTokenAction, - RefreshTokenAction, - ResetAuthenticationMessagesAction, SetAuthCookieStatus, - SetRedirectUrlAction, - SetUserAsIdleAction, - UnsetUserAsIdleAction -} from './auth.actions'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { RouteService } from '../services/route.service'; -import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; -import { AuthMethod } from './models/auth.method'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { RemoteData } from '../data/remote-data'; -import { environment } from '../../../environments/environment'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model'; -import { Group } from '../eperson/models/group.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { PageInfo } from '../shared/page-info.model'; -import { followLink } from '../../shared/utils/follow-link-config.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -65,7 +97,7 @@ export const IMPERSONATING_COOKIE = 'dsImpersonatingEPerson'; /** * The auth service. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class AuthService { /** @@ -90,13 +122,13 @@ export class AuthService { protected store: Store, protected hardRedirectService: HardRedirectService, private notificationService: NotificationsService, - private translateService: TranslateService + private translateService: TranslateService, ) { this.store.pipe( // when this service is constructed the store is not fully initialized yet filter((state: any) => state?.core?.auth !== undefined), select(isAuthenticated), - startWith(false) + startWith(false), ).subscribe((authenticated: boolean) => this._authenticated = authenticated); } @@ -136,7 +168,7 @@ export class AuthService { options.headers = headers; options.withCredentials = true; return this.authRequestService.getRequest('status', options).pipe( - map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) + map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)), ); } @@ -202,7 +234,7 @@ export class AuthService { */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { return this.epersonService.findByHref(userHref).pipe( - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -212,7 +244,7 @@ export class AuthService { */ public retrieveAuthenticatedUserById(userId: string): Observable { return this.epersonService.findById(userId).pipe( - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -225,7 +257,7 @@ export class AuthService { select(getAuthenticatedUserId), hasValueOperator(), switchMap((id: string) => this.epersonService.findById(id)), - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -248,7 +280,7 @@ export class AuthService { } else { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); } - }) + }), ); } @@ -260,15 +292,14 @@ export class AuthService { select(getAuthenticationToken), take(1), map((authTokenInfo: AuthTokenInfo) => { - let token: AuthTokenInfo; // Retrieve authentication token info and check if is valid - token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); + const token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken) && !this.isTokenExpired(token)) { return token; } else { throw false; } - }) + }), ); } @@ -412,7 +443,7 @@ export class AuthService { const token = this.getToken(); return token.expires - (60 * 5 * 1000) < Date.now(); } - }) + }), ); } @@ -437,7 +468,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; // Save cookie with the token return this.storage.set(TOKENITEM, token, options); @@ -516,7 +547,7 @@ export class AuthService { } else { return this.storage.get(REDIRECT_COOKIE); } - }) + }), ); } @@ -529,7 +560,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; this.storage.set(REDIRECT_COOKIE, url, options); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } @@ -609,7 +640,7 @@ export class AuthService { */ getShortlivedToken(): Observable { return this.isAuthenticated().pipe( - switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)) + switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)), ); } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 1ab1d2e0a5..eba6dc89f9 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,65 +1,64 @@ -import { Injectable } from '@angular/core'; +import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, - CanActivate, + CanActivateChildFn, + CanActivateFn, Router, RouterStateSnapshot, - UrlTree + UrlTree, } from '@angular/router'; - +import { + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { map, find, switchMap } from 'rxjs/operators'; -import { select, Store } from '@ngrx/store'; +import { + find, + map, + switchMap, +} from 'rxjs/operators'; -import { isAuthenticated, isAuthenticationLoading } from './selectors'; -import { AuthService, LOGIN_ROUTE } from './auth.service'; -import { CoreState } from '../core-state.model'; +import { AppState } from '../../app.reducer'; +import { + AuthService, + LOGIN_ROUTE, +} from './auth.service'; +import { + isAuthenticated, + isAuthenticationLoading, +} from './selectors'; /** * Prevent unauthorized activating and loading of routes - * @class AuthenticatedGuard + * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated + * @method canActivate */ -@Injectable() -export class AuthenticatedGuard implements CanActivate { +export const authenticatedGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + authService: AuthService = inject(AuthService), + router: Router = inject(Router), + store: Store = inject(Store), +): Observable => { + const url = state.url; + // redirect to sign in page if user is not authenticated + return store.pipe(select(isAuthenticationLoading)).pipe( + find((isLoading: boolean) => isLoading === false), + switchMap(() => store.pipe(select(isAuthenticated))), + map((authenticated) => { + if (authenticated) { + return authenticated; + } else { + authService.setRedirectUrl(url); + authService.removeToken(); + return router.createUrlTree([LOGIN_ROUTE]); + } + }), + ); +}; - /** - * @constructor - */ - constructor(private authService: AuthService, private router: Router, private store: Store) {} - - /** - * True when user is authenticated - * UrlTree with redirect to login page when user isn't authenticated - * @method canActivate - */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const url = state.url; - return this.handleAuth(url); - } - - /** - * True when user is authenticated - * UrlTree with redirect to login page when user isn't authenticated - * @method canActivateChild - */ - canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.canActivate(route, state); - } - - private handleAuth(url: string): Observable { - // redirect to sign in page if user is not authenticated - return this.store.pipe(select(isAuthenticationLoading)).pipe( - find((isLoading: boolean) => isLoading === false), - switchMap(() => this.store.pipe(select(isAuthenticated))), - map((authenticated) => { - if (authenticated) { - return authenticated; - } else { - this.authService.setRedirectUrl(url); - this.authService.removeToken(); - return this.router.createUrlTree([LOGIN_ROUTE]); - } - }) - ); - } -} +export const AuthenticatedGuardChild: CanActivateChildFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +) => authenticatedGuard(route, state); diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts index b41d981bcf..9649255b23 100644 --- a/src/app/core/auth/browser-auth-request.service.spec.ts +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -1,8 +1,9 @@ -import { AuthRequestService } from './auth-request.service'; -import { RequestService } from '../data/request.service'; -import { BrowserAuthRequestService } from './browser-auth-request.service'; import { Observable } from 'rxjs'; + import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { AuthRequestService } from './auth-request.service'; +import { BrowserAuthRequestService } from './browser-auth-request.service'; describe(`BrowserAuthRequestService`, () => { let href: string; @@ -12,7 +13,7 @@ describe(`BrowserAuthRequestService`, () => { beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { - 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2', }); service = new BrowserAuthRequestService(null, requestService, null); }); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts index 485e2ef9c4..d708cd8982 100644 --- a/src/app/core/auth/browser-auth-request.service.ts +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -1,10 +1,14 @@ import { Injectable } from '@angular/core'; -import { AuthRequestService } from './auth-request.service'; -import { PostRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthRequestService } from './auth-request.service'; /** * Client side version of the service to send authentication requests @@ -15,7 +19,7 @@ export class BrowserAuthRequestService extends AuthRequestService { constructor( halService: HALEndpointService, requestService: RequestService, - rdbService: RemoteDataBuildService + rdbService: RemoteDataBuildService, ) { super(halService, requestService, rdbService); } diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index d18b1ccf9a..f25e5a4892 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,7 +1,17 @@ -import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { + autoserialize, + deserialize, + deserializeAs, +} from 'cerialize'; import { Observable } from 'rxjs'; -import { link, typedObject } from '../../cache/builders/build-decorators'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { PaginatedList } from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; import { EPerson } from '../../eperson/models/eperson.model'; import { EPERSON } from '../../eperson/models/eperson.resource-type'; @@ -10,12 +20,10 @@ import { GROUP } from '../../eperson/models/group.resource-type'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { AuthMethod } from './auth.method'; import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; -import { AuthMethod } from './auth.method'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { PaginatedList } from '../../data/paginated-list.model'; /** * Object that represents the authenticated status of a user @@ -43,7 +51,7 @@ export class AuthStatus implements CacheableObject { * It is based on the ID, so it will be the same for each refresh. */ @deserializeAs(new IDToUUIDSerializer('auth-status'), 'id') - uuid: string; + uuid: string; /** * True if REST API is up and running, should never return false diff --git a/src/app/core/auth/models/short-lived-token.model.ts b/src/app/core/auth/models/short-lived-token.model.ts index 3786bd8e6a..5e8587d02d 100644 --- a/src/app/core/auth/models/short-lived-token.model.ts +++ b/src/app/core/auth/models/short-lived-token.model.ts @@ -1,10 +1,15 @@ +import { + autoserialize, + autoserializeAs, + deserialize, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; -import { ResourceType } from '../../shared/resource-type'; -import { SHORT_LIVED_TOKEN } from './short-lived-token.resource-type'; -import { HALLink } from '../../shared/hal-link.model'; import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { SHORT_LIVED_TOKEN } from './short-lived-token.resource-type'; /** * A short-lived token that can be used to authenticate a rest request diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index aba739edf6..6360377626 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -1,5 +1,7 @@ import { createSelector } from '@ngrx/store'; +import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; /** * Every reducer module's default export is the reducer function itself. In * addition, each module should export a type or interface that describes @@ -7,8 +9,6 @@ import { createSelector } from '@ngrx/store'; * notation packages up all of the exports into a single object. */ import { AuthState } from './auth.reducer'; -import { CoreState } from '../core-state.model'; -import { coreSelector } from '../core.selectors'; /** * Returns the user state. diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index 5b0221e5df..b119f6b3b7 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -1,14 +1,22 @@ -import { AuthRequestService } from './auth-request.service'; -import { RequestService } from '../data/request.service'; -import { ServerAuthRequestService } from './server-auth-request.service'; -import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; -import { Observable, of as observableOf } from 'rxjs'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + HttpClient, + HttpHeaders, + HttpResponse, +} from '@angular/common/http'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { XSRF_REQUEST_HEADER, - XSRF_RESPONSE_HEADER + XSRF_RESPONSE_HEADER, } from '../xsrf/xsrf.constants'; +import { AuthRequestService } from './auth-request.service'; +import { ServerAuthRequestService } from './server-auth-request.service'; describe(`ServerAuthRequestService`, () => { let href: string; @@ -22,20 +30,20 @@ describe(`ServerAuthRequestService`, () => { beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { - 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2', }); let headers = new HttpHeaders(); headers = headers.set(XSRF_RESPONSE_HEADER, mockToken); httpResponse = { body: { bar: false }, headers: headers, - statusText: '200' + statusText: '200', } as HttpResponse; httpClient = jasmine.createSpyObj('httpClient', { get: observableOf(httpResponse), }); halService = jasmine.createSpyObj('halService', { - 'getRootHref': '/api' + 'getRootHref': '/api', }); service = new ServerAuthRequestService(halService, requestService, null, httpClient); }); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index 058322acce..5f1828c71c 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -1,21 +1,22 @@ -import { Injectable } from '@angular/core'; -import { AuthRequestService } from './auth-request.service'; -import { PostRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { - HttpHeaders, HttpClient, - HttpResponse + HttpHeaders, + HttpResponse, } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { + DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, - DSPACE_XSRF_COOKIE } from '../xsrf/xsrf.constants'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { AuthRequestService } from './auth-request.service'; /** * Server side version of the service to send authentication requests @@ -45,11 +46,11 @@ export class ServerAuthRequestService extends AuthRequestService { map((response: HttpResponse) => response.headers.get(XSRF_RESPONSE_HEADER)), // Use that token to create an HttpHeaders object map((xsrfToken: string) => new HttpHeaders() - .set('Content-Type', 'application/json; charset=utf-8') - // set the token as the XSRF header - .set(XSRF_REQUEST_HEADER, xsrfToken) - // and as the DSPACE-XSRF-COOKIE - .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), + .set('Content-Type', 'application/json; charset=utf-8') + // set the token as the XSRF header + .set(XSRF_REQUEST_HEADER, xsrfToken) + // and as the DSPACE-XSRF-COOKIE + .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), map((headers: HttpHeaders) => // Create a new PostRequest using those headers and the given href new PostRequest( @@ -59,8 +60,8 @@ export class ServerAuthRequestService extends AuthRequestService { { headers: headers, }, - ) - ) + ), + ), ); } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index fc8ab18bfb..f51215abad 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,15 +1,17 @@ -import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; - +import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { RemoteData } from '../data/remote-data'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { RemoteData } from '../data/remote-data'; /** * The auth service. @@ -57,7 +59,7 @@ export class ServerAuthService extends AuthService { options.headers = headers; options.withCredentials = true; return this.authRequestService.getRequest('status', options).pipe( - map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) + map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)), ); } } diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts index a440325560..f4244e98b2 100644 --- a/src/app/core/auth/token-response-parsing.service.spec.ts +++ b/src/app/core/auth/token-response-parsing.service.spec.ts @@ -1,6 +1,6 @@ -import { TokenResponseParsingService } from './token-response-parsing.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { TokenResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { TokenResponseParsingService } from './token-response-parsing.service'; describe('TokenResponseParsingService', () => { let service: TokenResponseParsingService; @@ -13,10 +13,10 @@ describe('TokenResponseParsingService', () => { it('should return a TokenResponse containing the token', () => { const data = { payload: { - token: 'valid-token' + token: 'valid-token', }, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; const expected = new TokenResponse(data.payload.token, true, 200, 'OK'); expect(service.parse(undefined, data)).toEqual(expected); @@ -26,7 +26,7 @@ describe('TokenResponseParsingService', () => { const data = { payload: {}, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; const expected = new TokenResponse(null, false, 200, 'OK'); expect(service.parse(undefined, data)).toEqual(expected); @@ -36,7 +36,7 @@ describe('TokenResponseParsingService', () => { const data = { payload: {}, statusCode: 400, - statusText: 'BAD REQUEST' + statusText: 'BAD REQUEST', } as RawRestResponse; const expected = new TokenResponse(null, false, 400, 'BAD REQUEST'); expect(service.parse(undefined, data)).toEqual(expected); diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts index 1ba7a16b14..03a45521e9 100644 --- a/src/app/core/auth/token-response-parsing.service.ts +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -1,11 +1,15 @@ -import { ResponseParsingService } from '../data/parsing.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestResponse, TokenResponse } from '../cache/response.models'; -import { isNotEmpty } from '../../shared/empty.util'; import { Injectable } from '@angular/core'; -import { RestRequest } from '../data/rest-request.model'; -@Injectable() +import { isNotEmpty } from '../../shared/empty.util'; +import { + RestResponse, + TokenResponse, +} from '../cache/response.models'; +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/rest-request.model'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; + +@Injectable({ providedIn: 'root' }) /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a token string * wrapped in a TokenResponse diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts index b2ddade682..5628fe6583 100644 --- a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts @@ -1,31 +1,36 @@ -import { Injectable } from '@angular/core'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Bitstream } from '../shared/bitstream.model'; -import { BitstreamDataService } from '../data/bitstream-data.service'; import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { BitstreamBreadcrumbsService } from './bitstream-breadcrumbs.service'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; /** - * The class that resolves the BreadcrumbConfig object for an Item + * The resolve function that resolves the BreadcrumbConfig object for an Item */ -@Injectable({ - providedIn: 'root' -}) -export class BitstreamBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor( - protected breadcrumbService: BitstreamBreadcrumbsService, protected dataService: BitstreamDataService) { - super(breadcrumbService, dataService); - } +export const bitstreamBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: BitstreamBreadcrumbsService = inject(BitstreamBreadcrumbsService), + dataService: BitstreamDataService = inject(BitstreamDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = BITSTREAM_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; +}; - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return BITSTREAM_PAGE_LINKS_TO_FOLLOW; - } - -} diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts index 333886ed3d..11a3a74350 100644 --- a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts @@ -1,35 +1,46 @@ import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; - -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; -import { DSONameService } from './dso-name.service'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { LinkService } from '../cache/builders/link.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { RemoteData } from '../data/remote-data'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { getDSORoute } from '../../app-routing-paths'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { LinkService } from '../cache/builders/link.service'; import { BitstreamDataService } from '../data/bitstream-data.service'; -import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; +import { RemoteData } from '../data/remote-data'; import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { DSONameService } from './dso-name.service'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { constructor( protected bitstreamService: BitstreamDataService, protected linkService: LinkService, - protected dsoNameService: DSONameService + protected dsoNameService: DSONameService, ) { super(linkService, dsoNameService); } @@ -53,7 +64,7 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { return observableOf([]); }), - map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]), ); } @@ -74,12 +85,12 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { } else { return observableOf(undefined); } - }) + }), ); } else { return observableOf(undefined); } - }) + }), ); } } diff --git a/src/app/core/breadcrumbs/breadcrumbsProviderService.ts b/src/app/core/breadcrumbs/breadcrumbsProviderService.ts index 4f5dd0a583..83d9a2caaa 100644 --- a/src/app/core/breadcrumbs/breadcrumbsProviderService.ts +++ b/src/app/core/breadcrumbs/breadcrumbsProviderService.ts @@ -1,6 +1,7 @@ -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { Observable } from 'rxjs'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; + /** * Service to calculate breadcrumbs for a single part of the route */ diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts index 46c49add06..7df656a961 100644 --- a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts @@ -1,28 +1,36 @@ -import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { Collection } from '../shared/collection.model'; -import { CollectionDataService } from '../data/collection-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { COLLECTION_PAGE_LINKS_TO_FOLLOW } from '../../collection-page/collection-page.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CollectionDataService } from '../data/collection-data.service'; +import { Collection } from '../shared/collection.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for a Collection + * The resolve function that resolves the BreadcrumbConfig object for a Collection */ -@Injectable({ - providedIn: 'root' -}) -export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CollectionDataService) { - super(breadcrumbService, dataService); - } +export const collectionBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), + dataService: CollectionDataService = inject(CollectionDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = COLLECTION_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; +}; - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return COLLECTION_PAGE_LINKS_TO_FOLLOW; - } -} diff --git a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts index 309927771d..0c37b5ca4f 100644 --- a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts @@ -1,28 +1,50 @@ -import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../community-page/community-page.resolver'; +import { hasValue } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { CommunityDataService } from '../data/community-data.service'; import { Community } from '../shared/community.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../community-page/community-page.resolver'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { + DSOBreadcrumbResolver, + DSOBreadcrumbResolverByUuid, +} from './dso-breadcrumb.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for a Community + * The resolve function that resolves the BreadcrumbConfig object for a Community */ -@Injectable({ - providedIn: 'root' -}) -export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CommunityDataService) { - super(breadcrumbService, dataService); +export const communityBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), + dataService: CommunityDataService = inject(CommunityDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = COMMUNITY_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + if (hasValue(route.data.breadcrumbQueryParam) && hasValue(route.queryParams[route.data.breadcrumbQueryParam])) { + return DSOBreadcrumbResolverByUuid( + route, + state, + route.queryParams[route.data.breadcrumbQueryParam], + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; + } else { + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; } - - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return COMMUNITY_PAGE_LINKS_TO_FOLLOW; - } -} +}; diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts index e35e26e46f..c40a14a323 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts @@ -1,12 +1,12 @@ -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { Collection } from '../shared/collection.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getTestScheduler } from 'jasmine-marbles'; -import { CollectionBreadcrumbResolver } from './collection-breadcrumb.resolver'; + +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Collection } from '../shared/collection.model'; +import { collectionBreadcrumbResolver } from './collection-breadcrumb.resolver'; describe('DSOBreadcrumbResolver', () => { describe('resolve', () => { - let resolver: DSOBreadcrumbResolver; + let resolver: any; let collectionService: any; let dsoBreadcrumbService: any; let testCollection: Collection; @@ -16,18 +16,21 @@ describe('DSOBreadcrumbResolver', () => { beforeEach(() => { uuid = '1234-65487-12354-1235'; - breadcrumbUrl = '/collections/' + uuid; - currentUrl = breadcrumbUrl + '/edit'; - testCollection = Object.assign(new Collection(), { uuid }); + breadcrumbUrl = `/collections/${uuid}`; + currentUrl = `${breadcrumbUrl}/edit`; + testCollection = Object.assign(new Collection(), { + uuid: uuid, + type: 'collection', + }); dsoBreadcrumbService = {}; collectionService = { - findById: (id: string) => createSuccessfulRemoteDataObject$(testCollection) + findById: () => createSuccessfulRemoteDataObject$(testCollection), }; - resolver = new CollectionBreadcrumbResolver(dsoBreadcrumbService, collectionService); + resolver = collectionBreadcrumbResolver; }); it('should resolve a breadcrumb config for the correct DSO', () => { - const resolvedConfig = resolver.resolve({ params: { id: uuid } } as any, { url: currentUrl } as any); + const resolvedConfig = resolver({ params: { id: uuid } } as any, { url: currentUrl } as any, dsoBreadcrumbService, collectionService); const expectedConfig = { provider: dsoBreadcrumbService, key: testCollection, url: breadcrumbUrl }; getTestScheduler().expectObservable(resolvedConfig).toBe('(a|)', { a: expectedConfig }); }); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 8be4e5e099..992627ddfa 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -1,56 +1,69 @@ -import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { map } from 'rxjs/operators'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { map } from 'rxjs/operators'; + +import { getDSORoute } from '../../app-routing-paths'; +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { hasValue } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for a DSpaceObject + * Method for resolving a breadcrumb config object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {DSOBreadcrumbsService} breadcrumbService + * @param {IdentifiableDataService} dataService + * @param linksToFollow + * @returns BreadcrumbConfig object */ -@Injectable({ - providedIn: 'root', -}) -export abstract class DSOBreadcrumbResolver implements Resolve> { - protected constructor( - protected breadcrumbService: DSOBreadcrumbsService, - protected dataService: IdentifiableDataService, - ) { - } +export const DSOBreadcrumbResolver: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot, breadcrumbService: DSOBreadcrumbsService, dataService: IdentifiableDataService, ...linksToFollow: FollowLinkConfig[]) => Observable> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService, + dataService: IdentifiableDataService, + ...linksToFollow: FollowLinkConfig[] +): Observable> => { + return DSOBreadcrumbResolverByUuid(route, state, route.params.id, breadcrumbService, dataService, ...linksToFollow); +}; - /** - * Method for resolving a breadcrumb config object - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns BreadcrumbConfig object - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - const uuid = route.params.id; - return this.dataService.findById(uuid, true, false, ...this.followLinks).pipe( - getFirstCompletedRemoteData(), - getRemoteDataPayload(), - map((object: T) => { - if (hasValue(object)) { - const fullPath = state.url; - const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid; - return { provider: this.breadcrumbService, key: object, url: url }; - } else { - return undefined; - } - }) - ); - } - - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - abstract get followLinks(): FollowLinkConfig[]; -} +/** + * Method for resolving a breadcrumb config object with the given UUID + * + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {String} uuid The uuid of the DSO object + * @param {DSOBreadcrumbsService} breadcrumbService + * @param {IdentifiableDataService} dataService + * @param linksToFollow + * @returns BreadcrumbConfig object + */ +export const DSOBreadcrumbResolverByUuid: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot, uuid: string, breadcrumbService: DSOBreadcrumbsService, dataService: IdentifiableDataService, ...linksToFollow: FollowLinkConfig[]) => Observable> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + uuid: string, + breadcrumbService: DSOBreadcrumbsService, + dataService: IdentifiableDataService, + ...linksToFollow: FollowLinkConfig[] +): Observable> => { + return dataService.findById(uuid, true, false, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + map((object: DSpaceObject) => { + if (hasValue(object)) { + return { provider: breadcrumbService, key: object, url: getDSORoute(object) }; + } else { + return undefined; + } + }), + ); +}; diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts index 6b89c576d6..3869defa70 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts @@ -1,17 +1,24 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { getMockLinkService } from '../../shared/mocks/link-service.mock'; -import { LinkService } from '../cache/builders/link.service'; -import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { of as observableOf } from 'rxjs'; -import { Community } from '../shared/community.model'; -import { Collection } from '../shared/collection.model'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { getTestScheduler } from 'jasmine-marbles'; -import { DSONameService } from './dso-name.service'; +import { of as observableOf } from 'rxjs'; + import { getDSORoute } from '../../app-routing-paths'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getMockLinkService } from '../../shared/mocks/link-service.mock'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { LinkService } from '../cache/builders/link.service'; +import { Collection } from '../shared/collection.model'; +import { Community } from '../shared/community.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { Item } from '../shared/item.model'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { DSONameService } from './dso-name.service'; describe('DSOBreadcrumbsService', () => { let service: DSOBreadcrumbsService; @@ -43,46 +50,46 @@ describe('DSOBreadcrumbsService', () => { { type: 'community', metadata: { - 'dc.title': [{ value: 'community' }] + 'dc.title': [{ value: 'community' }], }, uuid: communityUUID, parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), _links: { parentCommunity: 'site', - self: communityPath + communityUUID - } - } + self: communityPath + communityUUID, + }, + }, ); testCollection = Object.assign(new Collection(), { type: 'collection', metadata: { - 'dc.title': [{ value: 'collection' }] + 'dc.title': [{ value: 'collection' }], }, uuid: collectionUUID, parentCommunity: createSuccessfulRemoteDataObject$(testCommunity), _links: { parentCommunity: communityPath + communityUUID, - self: communityPath + collectionUUID - } - } + self: communityPath + collectionUUID, + }, + }, ); testItem = Object.assign(new Item(), { type: 'item', metadata: { - 'dc.title': [{ value: 'item' }] + 'dc.title': [{ value: 'item' }], }, uuid: itemUUID, owningCollection: createSuccessfulRemoteDataObject$(testCollection), _links: { owningCollection: collectionPath + collectionUUID, - self: itemPath + itemUUID - } - } + self: itemPath + itemUUID, + }, + }, ); dsoNameService = { getName: (dso) => getName(dso) }; @@ -93,8 +100,8 @@ describe('DSOBreadcrumbsService', () => { TestBed.configureTestingModule({ providers: [ { provide: LinkService, useValue: getMockLinkService() }, - { provide: DSONameService, useValue: dsoNameService } - ] + { provide: DSONameService, useValue: dsoNameService }, + ], }).compileComponents(); })); diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 9a22cd0e35..7d2e3697e1 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -1,27 +1,35 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + find, + map, + switchMap, +} from 'rxjs/operators'; + +import { getDSORoute } from '../../app-routing-paths'; import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { hasValue } from '../../shared/empty.util'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { LinkService } from '../cache/builders/link.service'; +import { RemoteData } from '../data/remote-data'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; import { DSONameService } from './dso-name.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { LinkService } from '../cache/builders/link.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import { find, map, switchMap } from 'rxjs/operators'; -import { RemoteData } from '../data/remote-data'; -import { hasValue } from '../../shared/empty.util'; -import { Injectable } from '@angular/core'; -import { getDSORoute } from '../../app-routing-paths'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DSOBreadcrumbsService implements BreadcrumbsProviderService { constructor( protected linkService: LinkService, - protected dsoNameService: DSONameService + protected dsoNameService: DSONameService, ) { } @@ -46,7 +54,7 @@ export class DSOBreadcrumbsService implements BreadcrumbsProviderService [...breadcrumbs, crumb]) + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]), ); } } diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 9f2f76599a..90b08dca14 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -22,7 +22,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return ['Person', Item, DSpaceObject]; - } + }, }); mockOrgUnitName = 'Molecular Spectroscopy'; @@ -32,7 +32,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return ['OrgUnit', Item, DSpaceObject]; - } + }, }); mockDSOName = 'Lorem Ipsum'; @@ -42,7 +42,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return [DSpaceObject]; - } + }, }); service = new DSONameService({ instant: (a) => a } as any); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 8e4fb771c6..988141209f 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@angular/core'; -import { hasValue, isEmpty } from '../../shared/empty.util'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; + +import { + hasValue, + isEmpty, +} from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Metadata } from '../shared/metadata.utils'; /** @@ -9,7 +13,7 @@ import { Metadata } from '../shared/metadata.utils'; * on its render types. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DSONameService { @@ -55,7 +59,7 @@ export class DSONameService { Default: (dso: DSpaceObject): string => { // If object doesn't have dc.title metadata use name property return dso.firstMetadataValue('dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); - } + }, }; /** diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts index 0d1870487a..a85338c490 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts @@ -1,9 +1,9 @@ -import { I18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { i18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; -describe('I18nBreadcrumbResolver', () => { +describe('i18nBreadcrumbResolver', () => { describe('resolve', () => { - let resolver: I18nBreadcrumbResolver; + let resolver: any; let i18nBreadcrumbService: any; let i18nKey: string; let route: any; @@ -17,28 +17,28 @@ describe('I18nBreadcrumbResolver', () => { route = { data: { breadcrumbKey: i18nKey }, routeConfig: { - path: segment + path: segment, }, parent: { routeConfig: { - path: parentSegment - } - } as any + path: parentSegment, + }, + } as any, }; expectedPath = new URLCombiner(parentSegment, segment).toString(); i18nBreadcrumbService = {}; - resolver = new I18nBreadcrumbResolver(i18nBreadcrumbService); + resolver = i18nBreadcrumbResolver; }); it('should resolve the breadcrumb config', () => { - const resolvedConfig = resolver.resolve(route, {} as any); + const resolvedConfig = resolver(route, {} as any, i18nBreadcrumbService); const expectedConfig = { provider: i18nBreadcrumbService, key: i18nKey, url: expectedPath }; expect(resolvedConfig).toEqual(expectedConfig); }); it('should resolve throw an error when no breadcrumbKey is defined', () => { expect(() => { - resolver.resolve({ data: {} } as any, undefined); + resolver({ data: {} } as any, undefined, i18nBreadcrumbService); }).toThrow(); }); }); diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts index b3fadbbaa9..5f5c779211 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts @@ -1,32 +1,31 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; import { hasNoValue } from '../../shared/empty.util'; import { currentPathFromSnapshot } from '../../shared/utils/route.utils'; +import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; /** - * The class that resolves a BreadcrumbConfig object with an i18n key string for a route + * Method for resolving an I18n breadcrumb configuration object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {I18nBreadcrumbsService} breadcrumbService + * @returns BreadcrumbConfig object */ -@Injectable({ - providedIn: 'root' -}) -export class I18nBreadcrumbResolver implements Resolve> { - constructor(protected breadcrumbService: I18nBreadcrumbsService) { +export const i18nBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: I18nBreadcrumbsService = inject(I18nBreadcrumbsService), +): BreadcrumbConfig => { + const key = route.data.breadcrumbKey; + if (hasNoValue(key)) { + throw new Error('You provided an i18nBreadcrumbResolver for url \"' + route.url + '\" but no breadcrumbKey in the route\'s data'); } - - /** - * 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 { - const key = route.data.breadcrumbKey; - if (hasNoValue(key)) { - throw new Error('You provided an i18nBreadcrumbResolver for url \"' + route.url + '\" but no breadcrumbKey in the route\'s data'); - } - const fullPath = currentPathFromSnapshot(route); - return { provider: this.breadcrumbService, key: key, url: fullPath }; - } -} + const fullPath = currentPathFromSnapshot(route); + return { provider: breadcrumbService, key: key, url: fullPath }; +}; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts index ac2f244037..3fcd911a46 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts @@ -1,7 +1,14 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { getTestScheduler } from 'jasmine-marbles'; -import { BREADCRUMB_MESSAGE_POSTFIX, I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + BREADCRUMB_MESSAGE_POSTFIX, + I18nBreadcrumbsService, +} from './i18n-breadcrumbs.service'; describe('I18nBreadcrumbsService', () => { let service: I18nBreadcrumbsService; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts index 15563bdde8..5746f6faf2 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts @@ -1,7 +1,11 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + 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 @@ -12,7 +16,7 @@ export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; * Service to calculate i18n breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class I18nBreadcrumbsService implements BreadcrumbsProviderService { diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts index 3005b6f09a..cb16cedb42 100644 --- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -1,28 +1,35 @@ -import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item-page/item.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { ItemDataService } from '../data/item-data.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item-page/item.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for an Item + * The resolve function that resolves the BreadcrumbConfig object for an Item */ -@Injectable({ - providedIn: 'root' -}) -export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: ItemDataService) { - super(breadcrumbService, dataService); - } - - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return ITEM_PAGE_LINKS_TO_FOLLOW; - } -} +export const itemBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), + dataService: ItemDataService = inject(ItemDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = ITEM_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; +}; 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 0000000000..a6bbe49ddd --- /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: any; + 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 = navigationBreadcrumbResolver; + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver(route, state, NavigationBreadcrumbService); + 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 0000000000..ac306ee3f5 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts @@ -0,0 +1,52 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +/** + * Method for resolving an I18n breadcrumb configuration object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {NavigationBreadcrumbsService} breadcrumbService + * @returns BreadcrumbConfig object + */ +export const navigationBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: NavigationBreadcrumbsService = inject(NavigationBreadcrumbsService), +): BreadcrumbConfig => { + const parentRoutes: ActivatedRouteSnapshot[] = []; + getParentRoutes(route, parentRoutes); + const relatedRoutes = route.data.relatedRoutes; + const parentPaths = 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: breadcrumbService, key: combinedParentBreadcrumbKeys, url: combinedUrls }; +}; + +/** + * Method to collect all parent routes snapshot from current route snapshot + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {ActivatedRouteSnapshot[]} parentRoutes + */ +function getParentRoutes(route: ActivatedRouteSnapshot, parentRoutes: ActivatedRouteSnapshot[]): void { + if (route.parent) { + parentRoutes.push(route.parent); + getParentRoutes(route.parent, parentRoutes); + } +} 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 0000000000..2da8b06eab --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; + +/** + * 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 0000000000..646b967fe5 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts @@ -0,0 +1,47 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +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 index b6f4142469..7c2c34d479 100644 --- a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts @@ -1,8 +1,8 @@ -import { PublicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; +import { publicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; -describe('PublicationClaimBreadcrumbResolver', () => { +describe('publicationClaimBreadcrumbResolver', () => { describe('resolve', () => { - let resolver: PublicationClaimBreadcrumbResolver; + let resolver: any; let publicationClaimBreadcrumbService: any; const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a'; @@ -16,14 +16,14 @@ describe('PublicationClaimBreadcrumbResolver', () => { return this[param]; }, targetId: expectedId, - } + }, }; publicationClaimBreadcrumbService = {}; - resolver = new PublicationClaimBreadcrumbResolver(publicationClaimBreadcrumbService); + resolver = publicationClaimBreadcrumbResolver; }); it('should resolve the breadcrumb config', () => { - const resolvedConfig = resolver.resolve(route as any, {url: fullPath } as any); + const resolvedConfig = resolver(route as any, { url: fullPath } as any, publicationClaimBreadcrumbService); 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 index 713500d6a7..a1b52ce333 100644 --- a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts @@ -1,24 +1,18 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + 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 }; - } -} +export const publicationClaimBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: PublicationClaimBreadcrumbService = inject(PublicationClaimBreadcrumbService), +): BreadcrumbConfig => { + const targetId = route.paramMap.get('targetId').split(':')[1]; + return { provider: 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 index 11062210bb..8424b5edda 100644 --- a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts @@ -1,14 +1,18 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { getTestScheduler } from 'jasmine-marbles'; -import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { of } from 'rxjs'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + describe('PublicationClaimBreadcrumbService', () => { let service: PublicationClaimBreadcrumbService; let dsoNameService: any = { - getName: (str) => str + getName: (str) => str, }; let translateService: any = { instant: (str) => str, @@ -44,7 +48,7 @@ describe('PublicationClaimBreadcrumbService', () => { 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)] + 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 index 1a87fd7de6..43b7ed5761 100644 --- a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts @@ -1,22 +1,24 @@ -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 { + combineLatest, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { FeatureID } from '../data/feature-authorization/feature-id'; - - +import { ItemDataService } from '../data/item-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { DSONameService } from './dso-name.service'; /** * Service to calculate Publication claims breadcrumbs */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService { private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; @@ -38,9 +40,9 @@ export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderSer 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)]; - }) + 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 index 3544af62e7..fe2fe77e7f 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts @@ -1,8 +1,8 @@ -import {QualityAssuranceBreadcrumbResolver} from './quality-assurance-breadcrumb.resolver'; +import { qualityAssuranceBreadcrumbResolver } from './quality-assurance-breadcrumb.resolver'; -describe('QualityAssuranceBreadcrumbResolver', () => { +describe('qualityAssuranceBreadcrumbResolver', () => { describe('resolve', () => { - let resolver: QualityAssuranceBreadcrumbResolver; + let resolver: any; let qualityAssuranceBreadcrumbService: any; let route: any; const fullPath = '/test/quality-assurance/'; @@ -15,15 +15,15 @@ describe('QualityAssuranceBreadcrumbResolver', () => { return this[param]; }, sourceId: 'testSourceId', - topicId: 'testTopicId' - } + topicId: 'testTopicId', + }, }; qualityAssuranceBreadcrumbService = {}; - resolver = new QualityAssuranceBreadcrumbResolver(qualityAssuranceBreadcrumbService); + resolver = qualityAssuranceBreadcrumbResolver; }); it('should resolve the breadcrumb config', () => { - const resolvedConfig = resolver.resolve(route as any, {url: fullPath + 'testSourceId'} as any); + const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, qualityAssuranceBreadcrumbService); 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 index 6eb351ab1a..6507a75de6 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts @@ -1,32 +1,27 @@ -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'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; -@Injectable({ - providedIn: 'root' -}) -export class QualityAssuranceBreadcrumbResolver implements Resolve> { - constructor(protected breadcrumbService: QualityAssuranceBreadcrumbService) {} +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; - /** - * 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; +export const qualityAssuranceBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: QualityAssuranceBreadcrumbService = inject(QualityAssuranceBreadcrumbService), +): 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 }; + if (topicId) { + key += `:${topicId}`; } -} + const fullPath = state.url; + const url = fullPath.substring(0, fullPath.indexOf(sourceId)); + + return { provider: 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 index 4fef767214..f8d30754ca 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts @@ -1,11 +1,14 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { getTestScheduler } from 'jasmine-marbles'; -import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; describe('QualityAssuranceBreadcrumbService', () => { let service: QualityAssuranceBreadcrumbService; - let dataService: any; let translateService: any = { instant: (str) => str, }; @@ -26,14 +29,14 @@ describe('QualityAssuranceBreadcrumbService', () => { })); beforeEach(() => { - service = new QualityAssuranceBreadcrumbService(dataService,translateService); + 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)] + 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 index 209ae0722c..580a5e5f8e 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts @@ -1,25 +1,23 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; -import { Observable, of as observableOf } from 'rxjs'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { TranslateService } from '@ngx-translate/core'; -import { QualityAssuranceTopicDataService } from '../notifications/qa/topics/quality-assurance-topic-data.service'; - - /** * Service to calculate QA breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService { private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs'; constructor( - protected qualityAssuranceService: QualityAssuranceTopicDataService, private translationService: TranslateService, ) { @@ -32,18 +30,14 @@ export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderSer * @param url The url to use as a link for this breadcrumb */ getBreadcrumbs(key: string, url: string): Observable { - const sourceId = key.split(':')[0]; - const topicId = key.split(':')[1]; + const args = key.split(':'); + const sourceId = args[0]; + const topicId = args.length > 2 ? args[args.length - 1] : args[1]; if (topicId) { - return this.qualityAssuranceService.getTopic(topicId).pipe( - getFirstCompletedRemoteData(), - map((topic) => { - return [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), - new Breadcrumb(sourceId, `${url}${sourceId}`), - new Breadcrumb(topicId, undefined)]; - }) - ); + 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/browse/browse-definition-data.service.spec.ts b/src/app/core/browse/browse-definition-data.service.spec.ts index f321c2551c..affa63a548 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -1,11 +1,12 @@ -import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { EMPTY } from 'rxjs'; -import { FindListOptions } from '../data/find-list-options.model'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { RequestService } from '../data/request.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; + import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestService } from '../data/request.service'; +import { BrowseDefinitionDataService } from './browse-definition-data.service'; describe(`BrowseDefinitionDataService`, () => { let requestService: RequestService; @@ -18,7 +19,7 @@ describe(`BrowseDefinitionDataService`, () => { const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), - followLink('items') + followLink('items'), ]; function initTestService() { diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index bc495a51f4..9c0d0d16c9 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,33 +1,45 @@ // eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; -import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -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 { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { RemoteData } from '../data/remote-data'; -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 { dataService } from '../data/base/data-service.decorator'; -import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { take } from 'rxjs/operators'; -import { BrowseDefinitionRestRequest } from '../data/request.models'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; +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 { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + FindAllData, + FindAllDataImpl, +} from '../data/base/find-all-data'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from '../data/base/search-data'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { BrowseDefinitionRestRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; /** * Create a GET request for the given href, and send it. * Use a GET request specific for BrowseDefinitions. */ export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, - responseMsToLive: number, - href$: string | Observable, - useCachedVersionIfAvailable: boolean = true): void => { + responseMsToLive: number, + href$: string | Observable, + useCachedVersionIfAvailable: boolean = true): void => { if (isNotEmpty(href$)) { if (typeof href$ === 'string') { href$ = observableOf(href$); @@ -35,7 +47,7 @@ export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestS href$.pipe( isNotEmptyOperator(), - take(1) + take(1), ).subscribe((href: string) => { const requestId = requestService.generateRequestId(); const request = new BrowseDefinitionRestRequest(requestId, href); @@ -62,7 +74,6 @@ class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl @Injectable({ providedIn: 'root', }) -@dataService(BROWSE_DEFINITION) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { private findAllData: BrowseDefinitionFindAllDataImpl; private searchData: SearchDataImpl; @@ -150,7 +161,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService { let scheduler: TestScheduler; @@ -31,26 +42,26 @@ describe('BrowseService', () => { sortOptions: [ { name: 'title', - metadata: 'dc.title' + metadata: 'dc.title', }, { name: 'dateissued', - metadata: 'dc.date.issued' + metadata: 'dc.date.issued', }, { name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } + metadata: 'dc.date.accessioned', + }, ], defaultSortOrder: 'ASC', type: 'browse', metadataKeys: [ - 'dc.date.issued' + 'dc.date.issued', ], _links: { self: { href: 'https://rest.api/discover/browses/dateissued' }, - items: { href: 'https://rest.api/discover/browses/dateissued/items' } - } + items: { href: 'https://rest.api/discover/browses/dateissued/items' }, + }, }), Object.assign(new ValueListBrowseDefinition(), { id: 'author', @@ -58,28 +69,28 @@ describe('BrowseService', () => { sortOptions: [ { name: 'title', - metadata: 'dc.title' + metadata: 'dc.title', }, { name: 'dateissued', - metadata: 'dc.date.issued' + metadata: 'dc.date.issued', }, { name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } + metadata: 'dc.date.accessioned', + }, ], defaultSortOrder: 'ASC', type: 'browse', metadataKeys: [ 'dc.contributor.*', - 'dc.creator' + 'dc.creator', ], _links: { self: { href: 'https://rest.api/discover/browses/author' }, entries: { href: 'https://rest.api/discover/browses/author/entries' }, - items: { href: 'https://rest.api/discover/browses/author/items' } - } + items: { href: 'https://rest.api/discover/browses/author/items' }, + }, }), Object.assign(new HierarchicalBrowseDefinition(), { id: 'srsc', @@ -88,14 +99,14 @@ describe('BrowseService', () => { vocabulary: 'srsc', type: 'browse', metadata: [ - 'dc.subject' + 'dc.subject', ], _links: { vocabulary: { 'href': 'https://rest.api/submission/vocabularies/srsc/' }, items: { 'href': 'https://rest.api/discover/browses/srsc/items' }, entries: { 'href': 'https://rest.api/discover/browses/srsc/entries' }, - self: { 'href': 'https://rest.api/discover/browses/srsc' } - } + self: { 'href': 'https://rest.api/discover/browses/srsc' }, + }, }), ]; @@ -104,13 +115,13 @@ describe('BrowseService', () => { const getRequestEntry$ = (successful: boolean) => { return observableOf({ - response: { isSuccessful: successful, payload: browseDefinitions } as any + response: { isSuccessful: successful, payload: browseDefinitions } as any, } as RequestEntry); }; function initTestService() { browseDefinitionDataService = jasmine.createSpyObj('browseDefinitionDataService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(browseDefinitions)) + findAll: createSuccessfulRemoteDataObject$(createPaginatedList(browseDefinitions)), }); hrefOnlyDataService = getMockHrefOnlyDataService(); return new BrowseService( @@ -118,7 +129,7 @@ describe('BrowseService', () => { halService, browseDefinitionDataService, hrefOnlyDataService, - rdbService + rdbService, ); } @@ -164,7 +175,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); @@ -178,7 +189,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); @@ -193,7 +204,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); }); @@ -208,7 +219,7 @@ describe('BrowseService', () => { service = initTestService(); spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('--a-', { - a: createSuccessfulRemoteDataObject(createPaginatedList(browseDefinitions)) + a: createSuccessfulRemoteDataObject(createPaginatedList(browseDefinitions)), })); }); @@ -290,7 +301,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expectedURL + a: expectedURL, })); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 58bbc0b870..43f33f26e4 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,39 +1,51 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { + distinctUntilChanged, + map, + startWith, +} from 'rxjs/operators'; + +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SortDirection } from '../cache/models/sort-options.model'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; -import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { getBrowseDefinitionLinks, getFirstOccurrence, - getRemoteDataPayload, getFirstSucceededRemoteData, - getPaginatedListPayload + getPaginatedListPayload, + getRemoteDataPayload, } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { HrefOnlyDataService } from '../data/href-only-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { SortDirection } from '../cache/models/sort-options.model'; - +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('thumbnail') + followLink('thumbnail'), ]; /** * The service handling all browse requests */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class BrowseService { protected linkPath = 'browses'; @@ -102,7 +114,7 @@ export class BrowseService { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; - }) + }), ); if (options.fetchThumbnail ) { return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...BROWSE_LINKS_TO_FOLLOW); @@ -187,12 +199,12 @@ export class BrowseService { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; - }) + }), ); return this.hrefOnlyDataService.findListByHref(href$).pipe( getFirstSucceededRemoteData(), - getFirstOccurrence() + getFirstOccurrence(), ); } @@ -248,7 +260,7 @@ export class BrowseService { } return isNotEmpty(matchingKeys); - }) + }), ), map((def: BrowseDefinition) => { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { @@ -258,7 +270,7 @@ export class BrowseService { } }), startWith(undefined), - distinctUntilChanged() + distinctUntilChanged(), ); } diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index e4baaa4a5a..53f4cb2f7f 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,7 +1,12 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { dataService, getDataServiceFor, getLinkDefinition, link } from './build-decorators'; +import { + dataService, + getDataServiceFor, + getLinkDefinition, + link, +} from './build-decorators'; class TestHALResource implements HALResource { _links: { diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 8da1861ecf..be3ffc0f4d 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -1,26 +1,27 @@ -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { InjectionToken } from '@angular/core'; +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { GenericConstructor } from '../../shared/generic-constructor'; 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 { getResourceTypeValueFor } from '../object-cache.reducer'; import { TypedObject } from '../typed-object.model'; export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { providedIn: 'root', - factory: () => getDataServiceFor + 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', - factory: () => getLinkDefinitions + factory: () => getLinkDefinitions, }); const resolvedLinkKey = Symbol('resolvedLink'); @@ -122,7 +123,7 @@ export const link = ( resourceType, isList, linkName, - propertyName + propertyName, }); linkMap.set(target.constructor, targetMap); diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index 0ddfe05870..d987891388 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -1,15 +1,21 @@ /* eslint-disable max-classes-per-file */ -import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { + isEmpty, + take, +} from 'rxjs/operators'; + +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; +import { TestDataService } from '../../../shared/testing/test-data-service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; +import { + LINK_DEFINITION_FACTORY, + LINK_DEFINITION_MAP_FACTORY, +} from './build-decorators'; import { LinkService } from './link.service'; -import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; -import { isEmpty } from 'rxjs/operators'; -import { FindListOptions } from '../../data/find-list-options.model'; -import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; const TEST_MODEL = new ResourceType('testmodel'); let result: any; @@ -31,16 +37,9 @@ class TestModel implements HALResource { successor?: TestModel; } -@Injectable() -class TestDataService { - findListByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { - return 'findListByHref'; - } - - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { - return 'findByHref'; - } -} +const mockDataServiceMap: any = new Map([ + [TEST_MODEL.value, () => import('../../../shared/testing/test-data-service.mock').then(m => m.TestDataService)], +]); let testDataService: TestDataService; @@ -54,48 +53,54 @@ describe('LinkService', () => { value: 'a test value', _links: { self: { - href: 'http://self.link' + href: 'http://self.link', }, predecessor: { - href: 'http://predecessor.link' + href: 'http://predecessor.link', }, successor: { - href: 'http://successor.link' + href: 'http://successor.link', }, - } + }, }); testDataService = new TestDataService(); spyOn(testDataService, 'findListByHref').and.callThrough(); spyOn(testDataService, 'findByHref').and.callThrough(); TestBed.configureTestingModule({ - providers: [LinkService, { - provide: TestDataService, - useValue: testDataService - }, { - provide: DATA_SERVICE_FACTORY, - useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService), - }, { - provide: LINK_DEFINITION_FACTORY, - useValue: jasmine.createSpy('getLinkDefinition').and.returnValue({ - resourceType: TEST_MODEL, - linkName: 'predecessor', - propertyName: 'predecessor' - }), - }, { - provide: LINK_DEFINITION_MAP_FACTORY, - useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue([ - { + providers: [ + LinkService, + { + provide: TestDataService, + useValue: testDataService, + }, + { + provide: APP_DATA_SERVICES_MAP, + useValue: mockDataServiceMap, + }, + { + provide: LINK_DEFINITION_FACTORY, + useValue: jasmine.createSpy('getLinkDefinition').and.returnValue({ resourceType: TEST_MODEL, linkName: 'predecessor', propertyName: 'predecessor', - }, - { - resourceType: TEST_MODEL, - linkName: 'successor', - propertyName: 'successor', - } - ]), - }] + }), + }, + { + provide: LINK_DEFINITION_MAP_FACTORY, + useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue([ + { + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor', + }, + { + resourceType: TEST_MODEL, + linkName: 'successor', + propertyName: 'successor', + }, + ]), + }, + ], }); service = TestBed.inject(LinkService); }); @@ -103,10 +108,13 @@ describe('LinkService', () => { describe('resolveLink', () => { describe(`when the linkdefinition concerns a single object`, () => { beforeEach(() => { - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); + result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); }); - it('should call dataservice.findByHref with the correct href and nested links', () => { - expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); + it('should call dataservice.findByHref with the correct href and nested links', (done) => { + result.predecessor.pipe(take(1)).subscribe(() => { + expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); + done(); + }); }); }); describe(`when the linkdefinition concerns a list`, () => { @@ -115,12 +123,15 @@ describe('LinkService', () => { resourceType: TEST_MODEL, linkName: 'predecessor', propertyName: 'predecessor', - isList: true + isList: true, }); - service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); + result = service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); }); - it('should call dataservice.findListByHref with the correct href, findListOptions, and nested links', () => { - expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); + it('should call dataservice.findListByHref with the correct href, findListOptions, and nested links', (done) => { + result.predecessor.pipe(take(1)).subscribe((res) => { + expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); + done(); + }); }); }); describe('either way', () => { @@ -132,15 +143,14 @@ describe('LinkService', () => { expect((service as any).getLinkDefinition).toHaveBeenCalledWith(testModel.constructor as any, 'predecessor'); }); - it('should call getDataServiceFor with the correct resource type', () => { - expect((service as any).getDataServiceFor).toHaveBeenCalledWith(TEST_MODEL); - }); - - it('should return the model with the resolved link', () => { + it('should return the model with the resolved link', (done) => { expect(result.type).toBe(TEST_MODEL); expect(result.value).toBe('a test value'); expect(result._links.self.href).toBe('http://self.link'); - expect(result.predecessor).toBe('findByHref'); + result.predecessor.subscribe((res) => { + expect(res).toBe('findByHref'); + done(); + }); }); }); @@ -157,12 +167,16 @@ describe('LinkService', () => { describe(`when there is no dataservice for the resourcetype in the link`, () => { beforeEach(() => { - ((service as any).getDataServiceFor as jasmine.Spy).and.returnValue(undefined); + (service as any).map = {}; }); - it('should throw an error', () => { - expect(() => { - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); - }).toThrow(); + it('should throw an error', (done) => { + result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); + result.predecessor.subscribe({ + error: (error: unknown) => { + expect(error).toBeDefined(); + done(); + }, + }); }); }); }); @@ -213,12 +227,12 @@ describe('LinkService', () => { value: 'a test value', _links: { self: { - href: 'http://self.link' + href: 'http://self.link', }, predecessor: { - href: 'http://predecessor.link' - } - } + href: 'http://predecessor.link', + }, + }, }); }); @@ -227,8 +241,11 @@ describe('LinkService', () => { result = service.resolveLinks(testModel, followLink('predecessor')); }); - it('should return the model with the resolved link', () => { - expect(result.predecessor).toBe('findByHref'); + it('should return the model with the resolved link', (done) => { + result.predecessor.subscribe((res) => { + expect(res).toBe('findByHref'); + done(); + }); }); }); @@ -237,7 +254,7 @@ describe('LinkService', () => { ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({ resourceType: TEST_MODEL, linkName: 'successor', - propertyName: 'successor' + propertyName: 'successor', }); result = service.resolveLinks(testModel, followLink('successor')); }); diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index afc7ab88e4..6265e89d53 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -1,32 +1,48 @@ -import { Inject, Injectable, Injector } from '@angular/core'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { + Inject, + Injectable, + Injector, +} from '@angular/core'; +import { + EMPTY, + Observable, +} from 'rxjs'; +import { + catchError, + switchMap, +} from 'rxjs/operators'; + +import { + APP_DATA_SERVICES_MAP, + LazyDataServicesMap, +} from '../../../../config/app-config.interface'; +import { + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { HALDataService } from '../../data/base/hal-data-service.interface'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { lazyDataService } from '../../lazy-data-service'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; -import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY, LinkDefinition, } from './build-decorators'; -import { RemoteData } from '../../data/remote-data'; -import { EMPTY, Observable } from 'rxjs'; -import { ResourceType } from '../../shared/resource-type'; -import { HALDataService } from '../../data/base/hal-data-service.interface'; -import { PaginatedList } from '../../data/paginated-list.model'; /** * A Service to handle the resolving and removing * of resolved {@link HALLink}s on HALResources */ -@Injectable({ - providedIn: 'root', -}) +@Injectable({ providedIn: 'root' }) export class LinkService { constructor( - protected parentInjector: Injector, - @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>, + protected injector: Injector, + @Inject(APP_DATA_SERVICES_MAP) private map: LazyDataServicesMap, @Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: (source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition, @Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: (source: GenericConstructor) => Map>, ) { @@ -55,34 +71,32 @@ export class LinkService { */ public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable>> { const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); - if (hasValue(matchingLinkDef)) { - const provider = this.getDataServiceFor(matchingLinkDef.resourceType); + const lazyProvider$: Observable> = lazyDataService(this.map, matchingLinkDef.resourceType.value, this.injector); + return lazyProvider$.pipe( + switchMap((provider: HALDataService) => { + const link = model._links[matchingLinkDef.linkName]; + if (hasValue(link)) { + const href = link.href; - if (hasNoValue(provider)) { - throw new Error(`The @link() for ${String(linkToFollow.name)} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); - } - - const service: HALDataService = Injector.create({ - providers: [], - parent: this.parentInjector, - }).get(provider); - - const link = model._links[matchingLinkDef.linkName]; - if (hasValue(link)) { - const href = link.href; - - try { - if (matchingLinkDef.isList) { - return service.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); - } else { - return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + try { + if (matchingLinkDef.isList) { + return provider.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + } else { + return provider.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + } + } catch (e) { + console.error(`Something went wrong when using ${matchingLinkDef.resourceType.value}) ${hasValue(provider) ? '' : '(undefined) '}to resolve link ${String(linkToFollow.name)} at ${href}`); + throw e; + } } - } catch (e) { - console.error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${String(linkToFollow.name)} at ${href}`); - throw e; - } - } + + return EMPTY; + }), + catchError((err: unknown) => { + throw new Error(`The @link() for ${String(linkToFollow.name)} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); + }), + ); } else if (!linkToFollow.isOptional) { throw new Error(`followLink('${String(linkToFollow.name)}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${String(linkToFollow.name)}`); } diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index d9b856bb77..ec756ce85e 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -1,26 +1,43 @@ -import { createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { buildPaginatedList, PaginatedList } from '../../data/paginated-list.model'; -import { Item } from '../../shared/item.model'; -import { PageInfo } from '../../shared/page-info.model'; -import { RemoteDataBuildService } from './remote-data-build.service'; -import { ObjectCacheService } from '../object-cache.service'; -import { ITEM } from '../../shared/item.resource-type'; -import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; -import { LinkService } from './link.service'; -import { RequestService } from '../../data/request.service'; -import { UnCacheableObject } from '../../shared/uncacheable-object.model'; -import { RemoteData } from '../../data/remote-data'; -import { Observable, of as observableOf } from 'rxjs'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { take } from 'rxjs/operators'; -import { HALLink } from '../../shared/hal-link.model'; -import { RequestEntryState } from '../../data/request-entry-state.model'; -import { RequestEntry } from '../../data/request-entry.model'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { take } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; -import { fakeAsync, tick } from '@angular/core/testing'; + +import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { + createFailedRemoteDataObject, + createPendingRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../../../shared/remote-data.utils'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { RequestService } from '../../data/request.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { Item } from '../../shared/item.model'; +import { ITEM } from '../../shared/item.resource-type'; +import { PageInfo } from '../../shared/page-info.model'; +import { UnCacheableObject } from '../../shared/uncacheable-object.model'; +import { ObjectCacheService } from '../object-cache.service'; +import { LinkService } from './link.service'; +import { RemoteDataBuildService } from './remote-data-build.service'; describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; @@ -49,7 +66,7 @@ describe('RemoteDataBuildService', () => { linkService = getMockLinkService(); requestService = getMockRequestService(); unCacheableObject = { - foo: 'bar' + foo: 'bar', }; pageInfo = new PageInfo(); selfLink1 = 'https://rest.api/some/object'; @@ -64,31 +81,31 @@ describe('RemoteDataBuildService', () => { 'dc.title': [ { language: 'en_US', - value: 'Item nr 1' - } - ] + value: 'Item nr 1', + }, + ], }, _links: { self: { - href: selfLink1 - } - } + href: selfLink1, + }, + }, }), Object.assign(new Item(), { metadata: { 'dc.title': [ { language: 'en_US', - value: 'Item nr 2' - } - ] + value: 'Item nr 2', + }, + ], }, _links: { self: { - href: selfLink2 - } - } - }) + href: selfLink2, + }, + }, + }), ]; paginatedList = buildPaginatedList(pageInfo, array); normalizedPaginatedList = buildPaginatedList(pageInfo, array, true); @@ -96,43 +113,43 @@ describe('RemoteDataBuildService', () => { paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); entrySuccessCacheable = { request: { - uuid: '17820127-0ee5-4ed4-b6da-e654bdff8487' + uuid: '17820127-0ee5-4ed4-b6da-e654bdff8487', }, state: RequestEntryState.Success, response: { statusCode: 200, payloadLink: { - href: selfLink1 - } - } + href: selfLink1, + }, + }, } as RequestEntry; entrySuccessUnCacheable = { request: { - uuid: '0aa5ec06-d6a7-4e73-952e-1e0462bd1501' + uuid: '0aa5ec06-d6a7-4e73-952e-1e0462bd1501', }, state: RequestEntryState.Success, response: { statusCode: 200, unCacheableObject, - } + }, } as RequestEntry; entrySuccessNoContent = { request: { - uuid: '780a7295-6102-4a43-9775-80f2a4ff673c' + uuid: '780a7295-6102-4a43-9775-80f2a4ff673c', }, state: RequestEntryState.Success, response: { - statusCode: 204 + statusCode: 204, }, } as RequestEntry; entryError = { request: { - uuid: '1609dcbc-8442-4877-966e-864f151cc40c' + uuid: '1609dcbc-8442-4877-966e-864f151cc40c', }, state: RequestEntryState.Error, response: { statusCode: 500, - } + }, } as RequestEntry; requestEntry$ = observableOf(entrySuccessCacheable); linksToFollow = [ @@ -427,8 +444,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - payloadLink: { href: 'payload-link' } - } + payloadLink: { href: 'payload-link' }, + }, }; }); @@ -441,8 +458,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - payloadLink: undefined - } + payloadLink: undefined, + }, }; }); @@ -459,8 +476,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - unCacheableObject: Object.assign({}) - } + unCacheableObject: Object.assign({}), + }, }; }); @@ -472,7 +489,7 @@ describe('RemoteDataBuildService', () => { describe('when the entry\'s response doesn\'t contain an uncacheable object', () => { beforeEach(() => { entry = { - response: {} + response: {}, }; }); @@ -487,7 +504,7 @@ describe('RemoteDataBuildService', () => { it(`should return a new instance of that type`, () => { const source: any = { type: ITEM, - uuid: 'some-uuid' + uuid: 'some-uuid', }; const result = (service as any).plainObjectToInstance(source); @@ -503,7 +520,7 @@ describe('RemoteDataBuildService', () => { it(`should return a new plain JS object`, () => { const source: any = { type: 'foobar', - uuid: 'some-uuid' + uuid: 'some-uuid', }; const result = (service as any).plainObjectToInstance(source); @@ -528,7 +545,7 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { paginatedLinksToFollow = [ followLink('page', {}, ...linksToFollow), - ...linksToFollow + ...linksToFollow, ]; }); describe(`and the given list doesn't have a page property already`, () => { @@ -843,15 +860,15 @@ describe('RemoteDataBuildService', () => { it('should only emit after the callback is done', () => { testScheduler.run(({ cold: tsCold, expectObservable }) => { buildFromRequestUUIDSpy.and.returnValue( - tsCold('-p----s', RDs) + tsCold('-p----s', RDs), ); callback.and.returnValue( - tsCold(' --t', BOOLEAN) + tsCold(' --t', BOOLEAN), ); const done$ = service.buildFromRequestUUIDAndAwait('some-href', callback); expectObservable(done$).toBe( - ' -p------s', RDs // resulting duration between pending & successful includes the callback + ' -p------s', RDs, // resulting duration between pending & successful includes the callback ); }); }); 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 5b5e362406..36305b4a0c 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -5,29 +5,52 @@ import { Observable, of as observableOf, } from 'rxjs'; -import { map, switchMap, filter, distinctUntilKeyChanged, startWith } from 'rxjs/operators'; -import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util'; +import { + distinctUntilKeyChanged, + filter, + map, + startWith, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, + isUndefined, +} from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list.model'; +import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; import { RemoteData } from '../../data/remote-data'; import { RequestService } from '../../data/request.service'; -import { ObjectCacheService } from '../object-cache.service'; -import { LinkService } from './link.service'; -import { HALLink } from '../../shared/hal-link.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { getClassForType } from './build-decorators'; -import { HALResource } from '../../shared/hal-resource.model'; -import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; -import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; -import { getResourceTypeValueFor } from '../object-cache.reducer'; -import { hasSucceeded, isStale, RequestEntryState } from '../../data/request-entry-state.model'; -import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/request.operators'; import { RequestEntry } from '../../data/request-entry.model'; +import { + hasSucceeded, + isStale, + RequestEntryState, +} from '../../data/request-entry-state.model'; import { ResponseState } from '../../data/response-state.model'; +import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { HALLink } from '../../shared/hal-link.model'; +import { HALResource } from '../../shared/hal-resource.model'; import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { + getRequestFromRequestHref, + getRequestFromRequestUUID, +} from '../../shared/request.operators'; +import { getResourceTypeValueFor } from '../object-cache.reducer'; +import { ObjectCacheService } from '../object-cache.service'; +import { getClassForType } from './build-decorators'; +import { LinkService } from './link.service'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class RemoteDataBuildService { constructor(protected objectCache: ObjectCacheService, protected linkService: LinkService, @@ -77,7 +100,7 @@ export class RemoteDataBuildService { } } return [obj]; - }) + }), ); } @@ -151,7 +174,7 @@ export class RemoteDataBuildService { paginatedList.page = page .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow), ); if (isNotEmpty(otherLinks)) { return this.linkService.resolveLinks(paginatedList, ...otherLinks); @@ -164,7 +187,7 @@ export class RemoteDataBuildService { .filter((obj: any) => obj != null) .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow), ); if (isNotEmpty(otherLinks)) { return observableOf(this.linkService.resolveLinks(paginatedList, ...otherLinks)); @@ -230,7 +253,7 @@ export class RemoteDataBuildService { } else { return [rd]; } - }) + }), ); } @@ -273,12 +296,13 @@ export class RemoteDataBuildService { return isStale(r2.state) ? r1 : r2; } }), - distinctUntilKeyChanged('lastUpdated') ); const payload$ = this.buildPayload(requestEntry$, href$, ...linksToFollow); - return this.toRemoteDataObservable(requestEntry$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$).pipe( + distinctUntilKeyChanged('lastUpdated'), + ); } /** @@ -294,12 +318,12 @@ export class RemoteDataBuildService { toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { return observableCombineLatest([ requestEntry$, - payload$ + payload$, ]).pipe( filter(([entry,payload]: [RequestEntry, T]) => hasValue(entry) && // filter out cases where the state is successful, but the payload isn't yet set - !(hasSucceeded(entry.state) && isUndefined(payload)) + !(hasSucceeded(entry.state) && isUndefined(payload)), ), map(([entry, payload]: [RequestEntry, T]) => { let response = entry.response; @@ -314,9 +338,9 @@ export class RemoteDataBuildService { entry.state, response.errorMessage, payload, - response.statusCode + response.statusCode, ); - }) + }), ); } @@ -407,7 +431,7 @@ export class RemoteDataBuildService { state, errorMessage, payload, - statusCode + statusCode, ); })); } diff --git a/src/app/core/cache/cacheable-object.model.ts b/src/app/core/cache/cacheable-object.model.ts index b7d1609d58..86d041dab7 100644 --- a/src/app/core/cache/cacheable-object.model.ts +++ b/src/app/core/cache/cacheable-object.model.ts @@ -1,6 +1,6 @@ /* tslint:disable:max-classes-per-file */ -import { HALResource } from '../shared/hal-resource.model'; import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; import { TypedObject } from './typed-object.model'; /** diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index c18a20ffd6..5f8f60e1f1 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -1,8 +1,8 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { type } from '../../shared/ngrx/type'; -import { Operation } from 'fast-json-patch'; import { CacheableObject } from './cacheable-object.model'; /** @@ -15,7 +15,7 @@ export const ObjectCacheActionTypes = { ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH'), ADD_DEPENDENTS: type('dspace/core/cache/object/ADD_DEPENDENTS'), - REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS') + REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS'), }; /** diff --git a/src/app/core/cache/object-cache.effects.spec.ts b/src/app/core/cache/object-cache.effects.spec.ts index 3a50a5dbc7..66270be4c2 100644 --- a/src/app/core/cache/object-cache.effects.spec.ts +++ b/src/app/core/cache/object-cache.effects.spec.ts @@ -1,10 +1,14 @@ import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { ObjectCacheEffects } from './object-cache.effects'; -import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + import { StoreActionTypes } from '../../store.actions'; +import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { ObjectCacheEffects } from './object-cache.effects'; describe('ObjectCacheEffects', () => { let cacheEffects: ObjectCacheEffects; diff --git a/src/app/core/cache/object-cache.effects.ts b/src/app/core/cache/object-cache.effects.ts index fa2bf6f690..0de59a152c 100644 --- a/src/app/core/cache/object-cache.effects.ts +++ b/src/app/core/cache/object-cache.effects.ts @@ -1,6 +1,10 @@ -import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { map } from 'rxjs/operators'; import { StoreActionTypes } from '../../store.actions'; import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; @@ -16,9 +20,9 @@ export class ObjectCacheEffects { * This assumes that the server cached everything a negligible * time ago, and will likely need to be revisited later */ - fixTimestampsOnRehydrate = createEffect(() => this.actions$ + fixTimestampsOnRehydrate = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), - map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())) + map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())), )); constructor(private actions$: Actions) { diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 919edc8e57..7dda02a0f5 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; import { Operation } from 'fast-json-patch'; + import { Item } from '../shared/item.model'; import { AddDependentsObjectCacheAction, @@ -11,7 +12,6 @@ import { RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, } from './object-cache.actions'; - import { objectCacheReducer } from './object-cache.reducer'; class NullAction extends RemoveFromObjectCacheAction { @@ -54,7 +54,7 @@ describe('objectCacheReducer', () => { type: Item.type, self: selfLink2, foo: 'baz', - _links: { self: { href: selfLink2 } } + _links: { self: { href: selfLink2 } }, }, alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), @@ -62,8 +62,8 @@ describe('objectCacheReducer', () => { requestUUIDs: [requestUUID2], dependentRequestUUIDs: [requestUUID1], patches: [], - isDirty: false - } + isDirty: false, + }, }; deepFreeze(testState); @@ -126,6 +126,10 @@ describe('objectCacheReducer', () => { deepFreeze(state); objectCacheReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should remove the specified object from the cache in response to the REMOVE action', () => { @@ -149,6 +153,10 @@ describe('objectCacheReducer', () => { const action = new RemoveFromObjectCacheAction(selfLink1); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should set the timestamp of all objects in the cache in response to a RESET_TIMESTAMPS action', () => { @@ -164,16 +172,24 @@ describe('objectCacheReducer', () => { const action = new ResetObjectCacheTimestampsAction(new Date().getTime()); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_PATCH action without affecting the previous state', () => { const action = new AddPatchObjectCacheAction(selfLink1, [{ op: 'replace', path: '/name', - value: 'random string' + value: 'random string', }]); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should when the ADD_PATCH action dispatched', () => { diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index dc3f50db68..631ecd2209 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,18 +1,26 @@ /* eslint-disable max-classes-per-file */ +import { + applyPatch, + Operation, +} from 'fast-json-patch'; + +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { CacheEntry } from './cache-entry'; +import { CacheableObject } from './cacheable-object.model'; import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, ObjectCacheAction, - ObjectCacheActionTypes, RemoveDependentsObjectCacheAction, + ObjectCacheActionTypes, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, } from './object-cache.actions'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { CacheEntry } from './cache-entry'; -import { applyPatch, Operation } from 'fast-json-patch'; -import { CacheableObject } from './cacheable-object.model'; /** * An interface to represent a JsonPatch @@ -177,8 +185,8 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio dependentRequestUUIDs: existing.dependentRequestUUIDs || [], isDirty: isNotEmpty(existing.patches), patches: existing.patches || [], - alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] - } as ObjectCacheEntry + alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks], + } as ObjectCacheEntry, }); } @@ -217,7 +225,7 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject const newState = Object.create(null); Object.keys(state).forEach((key) => { newState[key] = Object.assign({}, state[key], { - timeCompleted: action.payload + timeCompleted: action.payload, }); }); return newState; @@ -241,7 +249,7 @@ function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCach const patches = newState[uuid].patches; newState[uuid] = Object.assign({}, newState[uuid], { patches: [...patches, { operations } as Patch], - isDirty: true + isDirty: true, }); } return newState; @@ -286,8 +294,8 @@ function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDepen ...new Set([ ...newState[href]?.dependentRequestUUIDs || [], ...action.payload.dependentRequestUUIDs, - ]) - ] + ]), + ], }); } @@ -308,7 +316,7 @@ function removeDependentsObjectCacheState(state: ObjectCacheState, action: Remov if (hasValue(newState[href])) { newState[href] = Object.assign({}, newState[href], { - dependentRequestUUIDs: [] + dependentRequestUUIDs: [], }); } diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 6af797be29..3d27f7252c 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,33 +1,44 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { cold } from 'jasmine-marbles'; -import { Store, StoreModule } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { Operation } from 'fast-json-patch'; -import { empty, of as observableOf } from 'rxjs'; +import { cold } from 'jasmine-marbles'; +import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; +import { + empty, + of as observableOf, +} from 'rxjs'; import { first } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; -import { coreReducers} from '../core.reducers'; +import { storeModuleConfig } from '../../app.reducer'; +import { coreReducers } from '../core.reducers'; +import { CoreState } from '../core-state.model'; import { RestRequestMethod } from '../data/rest-request-method'; +import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { IndexName } from '../index/index-name.model'; +import { HALLink } from '../shared/hal-link.model'; import { Item } from '../shared/item.model'; import { AddDependentsObjectCacheAction, - RemoveDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, } from './object-cache.actions'; import { Patch } from './object-cache.reducer'; import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { HALLink } from '../shared/hal-link.model'; -import { storeModuleConfig } from '../../app.reducer'; -import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; -import { IndexName } from '../index/index-name.model'; -import { CoreState } from '../core-state.model'; -import { TestScheduler } from 'rxjs/testing'; describe('ObjectCacheService', () => { let service: ObjectCacheService; @@ -69,8 +80,8 @@ describe('ObjectCacheService', () => { type: Item.type, _links: { self: { href: selfLink }, - anotherLink: { href: anotherLink } - } + anotherLink: { href: anotherLink }, + }, }; cacheEntry = { data: objectToCache, @@ -96,8 +107,8 @@ describe('ObjectCacheService', () => { 'cache/syncbuffer': {}, 'cache/object-updates': {}, 'data/request': {}, - 'index': {} - } + 'index': {}, + }, }; } @@ -105,12 +116,12 @@ describe('ObjectCacheService', () => { TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot(coreReducers, storeModuleConfig) + StoreModule.forRoot(coreReducers, storeModuleConfig), ], providers: [ provideMockStore({ initialState }), - { provide: ObjectCacheService, useValue: service } - ] + { provide: ObjectCacheService, useValue: service }, + ], }).compileComponents(); })); @@ -120,7 +131,7 @@ describe('ObjectCacheService', () => { mockStore = store as MockStore; mockStore.setState(initialState); linkServiceStub = { - removeResolvedLinks: (a) => a + removeResolvedLinks: (a) => a, }; spyOn(linkServiceStub, 'removeResolvedLinks').and.callThrough(); spyOn(store, 'dispatch'); @@ -209,7 +220,7 @@ describe('ObjectCacheService', () => { describe('getList', () => { it('should return an observable of the array of cached objects with the specified self link and type', () => { const item = Object.assign(new Item(), { - _links: { self: { href: selfLink } } + _links: { self: { href: selfLink } }, }); spyOn(service, 'getObjectByHref').and.returnValue(observableOf(item)); @@ -251,7 +262,7 @@ describe('ObjectCacheService', () => { 'something', 'something-else', 'specific-request', - ] + ], }))); }); @@ -266,7 +277,7 @@ describe('ObjectCacheService', () => { requestUUIDs: [ 'something', 'something-else', - ] + ], }))); }); @@ -292,9 +303,9 @@ describe('ObjectCacheService', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'cache/object': { - [selfLink]: cacheEntry - } - }) + [selfLink]: cacheEntry, + }, + }), }); mockStore.setState(state); const expected: TestColdObservable = cold('a', { a: cacheEntry }); @@ -310,14 +321,14 @@ describe('ObjectCacheService', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'cache/object': { - [selfLink]: cacheEntry + [selfLink]: cacheEntry, }, 'index': { 'object/alt-link-to-self-link': { - [anotherLink]: selfLink - } - } - }) + [anotherLink]: selfLink, + }, + }, + }), }); mockStore.setState(state); (service as any).getByAlternativeLink(anotherLink).subscribe(); @@ -335,8 +346,8 @@ describe('ObjectCacheService', () => { it('isDirty should return true when the patches list in the cache entry is not empty', () => { cacheEntry.patches = [ { - operations: operations - } as Patch + operations: operations, + } as Patch, ]; const result = (service as any).isDirty(cacheEntry); expect(result).toBe(true); @@ -371,9 +382,9 @@ describe('ObjectCacheService', () => { [anotherLink]: selfLink, ['objectWithoutDependentsAlt']: 'objectWithoutDependents', ['objectWithDependentsAlt']: 'objectWithDependents', - } - } - }) + }, + }, + }), }); mockStore.setState(state); }); @@ -421,11 +432,11 @@ describe('ObjectCacheService', () => { testScheduler.run(({ cold: tsCold, flush }) => { const href$ = tsCold('--y-n-n', { y: selfLink, - n: 'NOPE' + n: 'NOPE', }); const dependsOnHref$ = tsCold('-y-n-n', { y: 'objectWithoutDependents', - n: 'NOPE' + n: 'NOPE', }); service.addDependency(href$, dependsOnHref$); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 9ca0216210..69242732a5 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,32 +1,68 @@ import { Injectable } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { applyPatch, Operation } from 'fast-json-patch'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { + applyPatch, + Operation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + mergeMap, + switchMap, + take, +} from 'rxjs/operators'; -import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core-state.model'; +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; import { RestRequestMethod } from '../data/rest-request-method'; -import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; +import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { + selfLinkFromAlternativeLinkSelector, + selfLinkFromUuidSelector, +} from '../index/index.selectors'; +import { IndexName } from '../index/index-name.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; -import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; - -import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; -import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { HALLink } from '../shared/hal-link.model'; import { CacheableObject } from './cacheable-object.model'; -import { IndexName } from '../index/index-name.model'; +import { + AddDependentsObjectCacheAction, + AddPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, + RemoveFromObjectCacheAction, +} from './object-cache.actions'; +import { + ObjectCacheEntry, + ObjectCacheState, +} from './object-cache.reducer'; +import { AddToSSBAction } from './server-sync-buffer.actions'; /** * The base selector function to select the object cache in the store */ const objectCacheSelector = createSelector( coreSelector, - (state: CoreState) => state['cache/object'] + (state: CoreState) => state['cache/object'], ); /** @@ -42,11 +78,11 @@ const entryFromSelfLinkSelector = /** * A service to interact with the object cache */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ObjectCacheService { constructor( private store: Store, - private linkService: LinkService + private linkService: LinkService, ) { } @@ -82,12 +118,12 @@ export class ObjectCacheService { const cacheEntry$ = this.getByHref(href); const altLinks$ = cacheEntry$.pipe(map((entry: ObjectCacheEntry) => entry.alternativeLinks), take(1)); const childLinks$ = cacheEntry$.pipe(map((entry: ObjectCacheEntry) => { - return Object - .entries(entry.data._links) - .filter(([key, value]: [string, HALLink]) => key !== 'self') - .map(([key, value]: [string, HALLink]) => value.href); - }), - take(1) + return Object + .entries(entry.data._links) + .filter(([key, value]: [string, HALLink]) => key !== 'self') + .map(([key, value]: [string, HALLink]) => value.href); + }), + take(1), ); this.removeLinksFromAlternativeLinkIndex(altLinks$); this.removeLinksFromAlternativeLinkIndex(childLinks$); @@ -96,8 +132,8 @@ export class ObjectCacheService { private removeLinksFromAlternativeLinkIndex(links$: Observable) { links$.subscribe((links: string[]) => links.forEach((link: string) => { - this.store.dispatch(new RemoveFromIndexBySubstringAction(IndexName.ALTERNATIVE_OBJECT_LINK, link)); - } + this.store.dispatch(new RemoveFromIndexBySubstringAction(IndexName.ALTERNATIVE_OBJECT_LINK, link)); + }, )); } @@ -113,8 +149,8 @@ export class ObjectCacheService { Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getObjectByHref(selfLink) - ) + mergeMap((selfLink: string) => this.getObjectByHref(selfLink), + ), ); } @@ -129,14 +165,14 @@ export class ObjectCacheService { getObjectByHref(href: string): Observable { return this.getByHref(href).pipe( map((entry: ObjectCacheEntry) => { - if (isNotEmpty(entry.patches)) { - const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); - const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; - return Object.assign({}, entry, { data: patchedData }); - } else { - return entry; - } + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; + return Object.assign({}, entry, { data: patchedData }); + } else { + return entry; } + }, ), map((entry: ObjectCacheEntry) => { const type: GenericConstructor = getClassForType((entry.data as any).type); @@ -144,7 +180,7 @@ export class ObjectCacheService { throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); } return Object.assign(new type(), entry.data) as T; - }) + }), ); } @@ -162,13 +198,13 @@ export class ObjectCacheService { this.getBySelfLink(href), ]).pipe( map((results: ObjectCacheEntry[]) => results.find((entry: ObjectCacheEntry) => hasValue(entry))), - filter((entry: ObjectCacheEntry) => hasValue(entry)) + filter((entry: ObjectCacheEntry) => hasValue(entry)), ); } private getBySelfLink(selfLink: string): Observable { return this.store.pipe( - select(entryFromSelfLinkSelector(selfLink)) + select(entryFromSelfLinkSelector(selfLink)), ); } @@ -204,7 +240,7 @@ export class ObjectCacheService { getRequestUUIDByObjectUUID(uuid: string): Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)) + mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)), ); } @@ -232,7 +268,7 @@ export class ObjectCacheService { return observableOf([]); } else { return observableCombineLatest( - selfLinks.map((selfLink: string) => this.getObjectByHref(selfLink)) + selfLinks.map((selfLink: string) => this.getObjectByHref(selfLink)), ); } } @@ -252,7 +288,7 @@ export class ObjectCacheService { /* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/ this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - take(1) + take(1), ).subscribe((selfLink: string) => result = this.hasByHref(selfLink)); return result; @@ -290,9 +326,9 @@ export class ObjectCacheService { hasByHref$(href: string): Observable { return observableCombineLatest( this.getBySelfLink(href), - this.getByAlternativeLink(href) + this.getByAlternativeLink(href), ).pipe( - map((entries: ObjectCacheEntry[]) => entries.some((entry) => hasValue(entry))) + map((entries: ObjectCacheEntry[]) => entries.some((entry) => hasValue(entry))), ); } @@ -358,7 +394,7 @@ export class ObjectCacheService { observableCombineLatest([ href$, dependsOnHref$.pipe( - switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)) + switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)), ), ]).pipe( switchMap(([href, dependsOnSelfLink]: [string, string]) => { @@ -373,7 +409,7 @@ export class ObjectCacheService { this.getByHref(href).pipe( // only add the latest request to keep dependency index from growing indefinitely map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]), - ) + ), ]); }), take(1), diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 197bf130fb..9a09a49bc8 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,10 +1,10 @@ /* eslint-disable max-classes-per-file */ -import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; +import { RequestError } from '../data/request-error.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALLink } from '../shared/hal-link.model'; +import { PageInfo } from '../shared/page-info.model'; import { UnCacheableObject } from '../shared/uncacheable-object.model'; -import { RequestError } from '../data/request-error.model'; export class RestResponse { public toCache = true; @@ -13,7 +13,7 @@ export class RestResponse { constructor( public isSuccessful: boolean, public statusCode: number, - public statusText: string + public statusText: string, ) { } } @@ -29,7 +29,7 @@ export class DSOSuccessResponse extends RestResponse { public resourceSelfLinks: string[], public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -43,7 +43,7 @@ export class EndpointMapSuccessResponse extends RestResponse { constructor( public endpointMap: EndpointMap, public statusCode: number, - public statusText: string + public statusText: string, ) { super(true, statusCode, statusText); } @@ -64,7 +64,7 @@ export class ConfigSuccessResponse extends RestResponse { public configDefinition: ConfigObject, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -78,7 +78,7 @@ export class TokenResponse extends RestResponse { public token: string, public isSuccessful: boolean, public statusCode: number, - public statusText: string + public statusText: string, ) { super(isSuccessful, statusCode, statusText); } @@ -89,7 +89,7 @@ export class PostPatchSuccessResponse extends RestResponse { public dataDefinition: any, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -100,7 +100,7 @@ export class EpersonSuccessResponse extends RestResponse { public epersonDefinition: DSpaceObject[], public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -112,7 +112,7 @@ export class MessageResponse extends RestResponse { constructor( public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -124,7 +124,7 @@ export class TaskResponse extends RestResponse { constructor( public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -135,7 +135,7 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { public filterQuery: string, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 833c6b580f..889b3b7454 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -1,12 +1,22 @@ import { TestBed } from '@angular/core/testing'; - import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { storeModuleConfig } from '../../app.reducer'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; import { StoreMock } from '../../shared/testing/store.mock'; import { RequestService } from '../data/request.service'; import { RestRequestMethod } from '../data/rest-request-method'; @@ -16,11 +26,9 @@ import { ObjectCacheService } from './object-cache.service'; import { CommitSSBAction, EmptySSBAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; -import { storeModuleConfig } from '../../app.reducer'; -import { NoOpAction } from '../../shared/ngrx/no-op.action'; describe('ServerSyncBufferEffects', () => { let ssbEffects: ServerSyncBufferEffects; @@ -32,9 +40,9 @@ describe('ServerSyncBufferEffects', () => { autoSync: { timePerMethod: {}, - defaultTime: 0 - } - } + defaultTime: 0, + }, + }, }; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; let store; @@ -52,21 +60,21 @@ describe('ServerSyncBufferEffects', () => { provide: ObjectCacheService, useValue: { getObjectBySelfLink: (link) => { const object = Object.assign(new DSpaceObject(), { - _links: { self: { href: link } } + _links: { self: { href: link } }, }); return observableOf(object); }, getByHref: (link) => { const object = Object.assign(new DSpaceObject(), { _links: { - self: { href: link } - } + self: { href: link }, + }, }); return observableOf(object); - } - } + }, + }, }, - { provide: Store, useClass: StoreMock } + { provide: Store, useClass: StoreMock }, // other providers ], }); @@ -88,12 +96,12 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.ADD, - payload: { href: selfLink, method: RestRequestMethod.PUT } - } + payload: { href: selfLink, method: RestRequestMethod.PUT }, + }, }); expectObservable(ssbEffects.setTimeoutForServerSync).toBe('b', { - b: new CommitSSBAction(RestRequestMethod.PUT) + b: new CommitSSBAction(RestRequestMethod.PUT), }); }); }); @@ -108,8 +116,8 @@ describe('ServerSyncBufferEffects', () => { (state as any).core['cache/syncbuffer'] = { buffer: [{ href: selfLink, - method: RestRequestMethod.PATCH - }] + method: RestRequestMethod.PATCH, + }], }; }); }); @@ -117,13 +125,13 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.COMMIT, - payload: RestRequestMethod.PATCH - } + payload: RestRequestMethod.PATCH, + }, }); const expected = cold('(bc)', { b: new ApplyPatchObjectCacheAction(selfLink), - c: new EmptySSBAction(RestRequestMethod.PATCH) + c: new EmptySSBAction(RestRequestMethod.PATCH), }); expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); @@ -136,7 +144,7 @@ describe('ServerSyncBufferEffects', () => { .subscribe((state) => { (state as any).core = Object({}); (state as any).core['cache/syncbuffer'] = { - buffer: [] + buffer: [], }; }); }); @@ -145,8 +153,8 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.COMMIT, - payload: { method: RestRequestMethod.PATCH } - } + payload: { method: RestRequestMethod.PATCH }, + }, }); const expected = cold('b', { b: new NoOpAction() }); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 9571d4af5b..6f346d5bb3 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,27 +1,55 @@ -import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + delay, + exhaustMap, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { + hasValue, + isNotEmpty, + isNotUndefined, +} from '../../shared/empty.util'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; +import { PatchRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; +import { ObjectCacheEntry } from './object-cache.reducer'; +import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction, CommitSSBAction, EmptySSBAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; -import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { PatchRequest } from '../data/request.models'; -import { ObjectCacheService } from './object-cache.service'; -import { ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { environment } from '../../../environments/environment'; -import { ObjectCacheEntry } from './object-cache.reducer'; -import { Operation } from 'fast-json-patch'; -import { NoOpAction } from '../../shared/ngrx/no-op.action'; -import { CoreState } from '../core-state.model'; +import { + ServerSyncBufferEntry, + ServerSyncBufferState, +} from './server-sync-buffer.reducer'; @Injectable() export class ServerSyncBufferEffects { @@ -32,7 +60,7 @@ export class ServerSyncBufferEffects { * Then dispatch a CommitSSBAction * When the delay is running, no new AddToSSBActions are processed in this effect */ - setTimeoutForServerSync = createEffect(() => this.actions$ + setTimeoutForServerSync = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.ADD), exhaustMap((action: AddToSSBAction) => { @@ -41,7 +69,7 @@ export class ServerSyncBufferEffects { return observableOf(new CommitSSBAction(action.payload.method)).pipe( delay(timeoutInSeconds * 1000), ); - }) + }), )); /** @@ -50,7 +78,7 @@ export class ServerSyncBufferEffects { * When the list of actions is not empty, also dispatch an EmptySSBAction * When the list is empty dispatch a NO_ACTION placeholder action */ - commitServerSyncBuffer = createEffect(() => this.actions$ + commitServerSyncBuffer = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.COMMIT), switchMap((action: CommitSSBAction) => { @@ -78,14 +106,14 @@ export class ServerSyncBufferEffects { /* Add extra action to array, to make sure the ServerSyncBuffer is emptied afterwards */ if (isNotEmpty(actions) && isNotUndefined(actions[0])) { return observableCombineLatest(...actions).pipe( - switchMap((array) => [...array, new EmptySSBAction(action.payload)]) - ); + switchMap((array) => [...array, new EmptySSBAction(action.payload)]), + ); } else { return observableOf(new NoOpAction()); } - }) + }), ); - }) + }), )); /** @@ -96,7 +124,7 @@ export class ServerSyncBufferEffects { */ private applyPatch(href: string): Observable { const patchObject = this.objectCache.getByHref(href).pipe( - take(1) + take(1), ); return patchObject.pipe( @@ -108,7 +136,7 @@ export class ServerSyncBufferEffects { } } return new ApplyPatchObjectCacheAction(href); - }) + }), ); } diff --git a/src/app/core/cache/server-sync-buffer.reducer.spec.ts b/src/app/core/cache/server-sync-buffer.reducer.spec.ts index 51ba010c1e..d986581ce2 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.spec.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -1,9 +1,13 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; -import { RemoveFromObjectCacheAction } from './object-cache.actions'; -import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; + import { RestRequestMethod } from '../data/rest-request-method'; -import { AddToSSBAction, EmptySSBAction } from './server-sync-buffer.actions'; +import { RemoveFromObjectCacheAction } from './object-cache.actions'; +import { + AddToSSBAction, + EmptySSBAction, +} from './server-sync-buffer.actions'; +import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; class NullAction extends RemoveFromObjectCacheAction { type = null; @@ -27,8 +31,8 @@ describe('serverSyncBufferReducer', () => { { href: selfLink2, method: RestRequestMethod.GET, - } - ] + }, + ], }; const newSelfLink = 'https://localhost:8080/api/core/items/1ce6b5ae-97e1-4e5a-b4b0-f9029bad10c0'; @@ -52,12 +56,20 @@ describe('serverSyncBufferReducer', () => { const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the EMPTY action without affecting the previous state', () => { const action = new EmptySSBAction(); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => { @@ -79,7 +91,7 @@ describe('serverSyncBufferReducer', () => { // testState has already been frozen above const newState = serverSyncBufferReducer(testState, action); expect(newState.buffer).toContain({ - href: newSelfLink, method: RestRequestMethod.PUT + href: newSelfLink, method: RestRequestMethod.PUT, }) ; }); diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e8944aa73..f1ae894315 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -1,11 +1,14 @@ -import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; +import { RestRequestMethod } from '../data/rest-request-method'; import { AddToSSBAction, EmptySSBAction, ServerSyncBufferAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; -import { RestRequestMethod } from '../data/rest-request-method'; /** * An entry in the ServerSyncBufferState @@ -86,9 +89,9 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi * the new state, with a new entry added to the buffer */ function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState { - let newBuffer = []; - if (hasValue(action.payload)) { - newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); - } - return Object.assign({}, state, { buffer: newBuffer }); + let newBuffer = []; + if (hasValue(action.payload)) { + newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); + } + return Object.assign({}, state, { buffer: newBuffer }); } 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 0000000000..706c8f684b --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts @@ -0,0 +1,40 @@ +import { of } from 'rxjs'; + +import { notifyInfoGuard } from './notify-info.guard'; + +describe('notifyInfoGuard', () => { + let guard: any; + let notifyInfoServiceSpy: any; + let router: any; + + beforeEach(() => { + notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['isCoarConfigEnabled']); + router = jasmine.createSpyObj('Router', ['parseUrl']); + guard = notifyInfoGuard; + }); + + it('should be created', () => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true)); + expect(guard(null, null, notifyInfoServiceSpy, router)).toBeTruthy(); + }); + + it('should return true if COAR config is enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true)); + + guard(null, null, notifyInfoServiceSpy, router).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(null, null, notifyInfoServiceSpy, router).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 0000000000..1025e7b62b --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.ts @@ -0,0 +1,23 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { NotifyInfoService } from './notify-info.service'; + +export const notifyInfoGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + notifyInfoService: NotifyInfoService = inject(NotifyInfoService), + router: Router = inject(Router), +): Observable => { + return notifyInfoService.isCoarConfigEnabled().pipe( + map(isEnabled => isEnabled ? true : 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 0000000000..6fa8295be0 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { NotifyInfoService } from './notify-info.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 }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + ], + }); + 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', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['true'] })); + + service.isCoarConfigEnabled().subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it('should retrieve and map LDN local inbox URLs', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['inbox1', 'inbox2'] })); + + service.getCoarLdnLocalInboxUrls().subscribe((result) => { + expect(result).toEqual(['inbox1', 'inbox2']); + done(); + }); + }); + + 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 0000000000..455c7902ee --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { + map, + Observable, +} from 'rxjs'; + +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../data/feature-authorization/feature-id'; +import { RemoteData } from '../../data/remote-data'; +import { ConfigurationProperty } from '../../shared/configuration-property.model'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; + +/** + * 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( + getFirstCompletedRemoteData(), + map((responseRD: RemoteData) => responseRD.hasSucceeded ? responseRD.payload.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/config/bulk-access-config-data.service.ts b/src/app/core/config/bulk-access-config-data.service.ts index 28b4029ea2..8023e58489 100644 --- a/src/app/core/config/bulk-access-config-data.service.ts +++ b/src/app/core/config/bulk-access-config-data.service.ts @@ -1,17 +1,15 @@ import { Injectable } from '@angular/core'; + 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 '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigDataService } from './config-data.service'; -import { dataService } from '../data/base/data-service.decorator'; -import { BULK_ACCESS_CONDITION_OPTIONS } from './models/config-type'; /** * Data Service responsible for retrieving Bulk Access Condition Options from the REST API */ @Injectable({ providedIn: 'root' }) -@dataService(BULK_ACCESS_CONDITION_OPTIONS) export class BulkAccessConfigDataService extends ConfigDataService { constructor( diff --git a/src/app/core/config/config-data.service.spec.ts b/src/app/core/config/config-data.service.spec.ts index 38340d1ad5..a9979f1bb5 100644 --- a/src/app/core/config/config-data.service.spec.ts +++ b/src/app/core/config/config-data.service.spec.ts @@ -1,15 +1,16 @@ import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { ConfigDataService } from './config-data.service'; -import { RequestService } from '../data/request.service'; -import { GetRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { FindListOptions } from '../data/find-list-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { GetRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; diff --git a/src/app/core/config/config-data.service.ts b/src/app/core/config/config-data.service.ts index 58b023e62c..51ca655921 100644 --- a/src/app/core/config/config-data.service.ts +++ b/src/app/core/config/config-data.service.ts @@ -1,11 +1,11 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ConfigObject } from './models/config.model'; -import { RemoteData } from '../data/remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { RemoteData } from '../data/remote-data'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { ConfigObject } from './models/config.model'; /** * Abstract data service to retrieve configuration objects from the REST server. diff --git a/src/app/core/config/models/bulk-access-condition-options.model.ts b/src/app/core/config/models/bulk-access-condition-options.model.ts index d84e14b95d..514c682b4e 100644 --- a/src/app/core/config/models/bulk-access-condition-options.model.ts +++ b/src/app/core/config/models/bulk-access-condition-options.model.ts @@ -1,8 +1,13 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { + autoserialize, + autoserializeAs, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { ResourceType } from '../../shared/resource-type'; import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; import { ConfigObject } from './config.model'; import { AccessesConditionOption } from './config-accesses-conditions-options.model'; import { BULK_ACCESS_CONDITION_OPTIONS } from './config-type'; diff --git a/src/app/core/config/models/config-accesses-conditions-options.model.ts b/src/app/core/config/models/config-accesses-conditions-options.model.ts index 244b501908..64199be0eb 100644 --- a/src/app/core/config/models/config-accesses-conditions-options.model.ts +++ b/src/app/core/config/models/config-accesses-conditions-options.model.ts @@ -3,43 +3,43 @@ */ export class AccessesConditionOption { - /** + /** * The name for this Access Condition */ - name: string; + name: string; - /** + /** * The groupName for this Access Condition */ - groupName: string; + groupName: string; - /** + /** * A boolean representing if this Access Condition has a start date */ - hasStartDate: boolean; + hasStartDate: boolean; - /** + /** * A boolean representing if this Access Condition has an end date */ - hasEndDate: boolean; + hasEndDate: boolean; - /** + /** * Maximum value of the start date */ - endDateLimit?: string; + endDateLimit?: string; - /** + /** * Maximum value of the end date */ - startDateLimit?: string; + startDateLimit?: string; - /** + /** * Maximum value of the start date */ - maxStartDate?: string; + maxStartDate?: string; - /** + /** * Maximum value of the end date */ - maxEndDate?: string; + maxEndDate?: string; } diff --git a/src/app/core/config/models/config-submission-access.model.ts b/src/app/core/config/models/config-submission-access.model.ts index 7db96acf2b..2e9a929183 100644 --- a/src/app/core/config/models/config-submission-access.model.ts +++ b/src/app/core/config/models/config-submission-access.model.ts @@ -1,9 +1,14 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { AccessesConditionOption } from './config-accesses-conditions-options.model'; import { SUBMISSION_ACCESSES_TYPE } from './config-type'; -import { HALLink } from '../../shared/hal-link.model'; /** * Class for the configuration describing the item accesses condition diff --git a/src/app/core/config/models/config-submission-accesses.model.ts b/src/app/core/config/models/config-submission-accesses.model.ts index 3f8004928d..b3c097cc8a 100644 --- a/src/app/core/config/models/config-submission-accesses.model.ts +++ b/src/app/core/config/models/config-submission-accesses.model.ts @@ -1,7 +1,8 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { SUBMISSION_ACCESSES_TYPE } from './config-type'; import { SubmissionAccessModel } from './config-submission-access.model'; +import { SUBMISSION_ACCESSES_TYPE } from './config-type'; @typedObject @inheritSerialization(SubmissionAccessModel) diff --git a/src/app/core/config/models/config-submission-definition.model.ts b/src/app/core/config/models/config-submission-definition.model.ts index b07917e032..eda4f54340 100644 --- a/src/app/core/config/models/config-submission-definition.model.ts +++ b/src/app/core/config/models/config-submission-definition.model.ts @@ -1,9 +1,14 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { PaginatedList } from '../../data/paginated-list.model'; import { HALLink } from '../../shared/hal-link.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; import { ConfigObject } from './config.model'; +import { SubmissionSectionModel } from './config-submission-section.model'; import { SUBMISSION_DEFINITION_TYPE } from './config-type'; /** diff --git a/src/app/core/config/models/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts index 08f1ef17bb..790334da9b 100644 --- a/src/app/core/config/models/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionDefinitionModel } from './config-submission-definition.model'; import { SUBMISSION_DEFINITIONS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-form.model.ts b/src/app/core/config/models/config-submission-form.model.ts index 90f94882bd..c524a83916 100644 --- a/src/app/core/config/models/config-submission-form.model.ts +++ b/src/app/core/config/models/config-submission-form.model.ts @@ -1,7 +1,11 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + inheritSerialization, +} from 'cerialize'; + +import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { typedObject } from '../../cache/builders/build-decorators'; import { ConfigObject } from './config.model'; -import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { SUBMISSION_FORM_TYPE } from './config-type'; /** diff --git a/src/app/core/config/models/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts index 506905d88c..4cf71d85d9 100644 --- a/src/app/core/config/models/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionFormModel } from './config-submission-form.model'; import { SUBMISSION_FORMS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts index bdc884dfa4..feb4fc9cb0 100644 --- a/src/app/core/config/models/config-submission-section.model.ts +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -1,4 +1,9 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { SectionsType } from '../../../submission/sections/sections-type'; import { typedObject } from '../../cache/builders/build-decorators'; import { HALLink } from '../../shared/hal-link.model'; diff --git a/src/app/core/config/models/config-submission-sections.model.ts b/src/app/core/config/models/config-submission-sections.model.ts index 423ea99b1e..86894b6e44 100644 --- a/src/app/core/config/models/config-submission-sections.model.ts +++ b/src/app/core/config/models/config-submission-sections.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionSectionModel } from './config-submission-section.model'; import { SUBMISSION_SECTIONS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-upload.model.ts b/src/app/core/config/models/config-submission-upload.model.ts index f6897da2e3..cabc84d0f5 100644 --- a/src/app/core/config/models/config-submission-upload.model.ts +++ b/src/app/core/config/models/config-submission-upload.model.ts @@ -1,12 +1,23 @@ -import { autoserialize, inheritSerialization, deserialize } from 'cerialize'; -import { typedObject, link } from '../../cache/builders/build-decorators'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; +import { Observable } from 'rxjs'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { RemoteData } from '../../data/remote-data'; +import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { AccessConditionOption } from './config-access-condition-option.model'; import { SubmissionFormsModel } from './config-submission-forms.model'; -import { SUBMISSION_UPLOAD_TYPE, SUBMISSION_FORMS_TYPE } from './config-type'; -import { HALLink } from '../../shared/hal-link.model'; -import { RemoteData } from '../../data/remote-data'; -import { Observable } from 'rxjs'; +import { + SUBMISSION_FORMS_TYPE, + SUBMISSION_UPLOAD_TYPE, +} from './config-type'; @typedObject @inheritSerialization(ConfigObject) diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts index 8fb7dc66b9..235cdd31a1 100644 --- a/src/app/core/config/models/config-submission-uploads.model.ts +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -1,7 +1,8 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { SUBMISSION_UPLOADS_TYPE } from './config-type'; import { SubmissionUploadModel } from './config-submission-upload.model'; +import { SUBMISSION_UPLOADS_TYPE } from './config-type'; @typedObject @inheritSerialization(SubmissionUploadModel) diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 170aa334ed..74d090b89d 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,8 +1,12 @@ -import { autoserialize, deserialize } from 'cerialize'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { CacheableObject } from '../../cache/cacheable-object.model'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { CacheableObject } from '../../cache/cacheable-object.model'; export abstract class ConfigObject implements CacheableObject { diff --git a/src/app/core/config/submission-accesses-config-data.service.ts b/src/app/core/config/submission-accesses-config-data.service.ts index d2da0fce42..bc7a7e66d3 100644 --- a/src/app/core/config/submission-accesses-config-data.service.ts +++ b/src/app/core/config/submission-accesses-config-data.service.ts @@ -1,22 +1,20 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; import { SubmissionAccessesModel } from './models/config-submission-accesses.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. */ -@Injectable() -@dataService(SUBMISSION_ACCESSES_TYPE) +@Injectable({ providedIn: 'root' }) export class SubmissionAccessesConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/config/submission-forms-config-data.service.ts b/src/app/core/config/submission-forms-config-data.service.ts index f4c0690685..fe1234defb 100644 --- a/src/app/core/config/submission-forms-config-data.service.ts +++ b/src/app/core/config/submission-forms-config-data.service.ts @@ -1,22 +1,20 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; -import { RequestService } from '../data/request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ConfigObject } from './models/config.model'; -import { SUBMISSION_FORMS_TYPE } from './models/config-type'; -import { SubmissionFormsModel } from './models/config-submission-forms.model'; import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; +import { ConfigObject } from './models/config.model'; +import { SubmissionFormsModel } from './models/config-submission-forms.model'; /** * Data service to retrieve submission form configuration objects from the REST server. */ -@Injectable() -@dataService(SUBMISSION_FORMS_TYPE) +@Injectable({ providedIn: 'root' }) export class SubmissionFormsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/config/submission-uploads-config-data.service.ts b/src/app/core/config/submission-uploads-config-data.service.ts index 8f838352a9..10d749080e 100644 --- a/src/app/core/config/submission-uploads-config-data.service.ts +++ b/src/app/core/config/submission-uploads-config-data.service.ts @@ -1,22 +1,20 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; import { SubmissionUploadsModel } from './models/config-submission-uploads.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. */ -@Injectable() -@dataService(SUBMISSION_UPLOADS_TYPE) +@Injectable({ providedIn: 'root' }) export class SubmissionUploadsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/core-state.model.ts b/src/app/core/core-state.model.ts index b8211fdb55..2128901754 100644 --- a/src/app/core/core-state.model.ts +++ b/src/app/core/core-state.model.ts @@ -1,16 +1,14 @@ -import { - BitstreamFormatRegistryState -} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { BitstreamFormatRegistryState } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { AuthState } from './auth/auth.reducer'; import { ObjectCacheState } from './cache/object-cache.reducer'; import { ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; import { ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; +import { RequestState } from './data/request-state.model'; import { HistoryState } from './history/history.reducer'; import { MetaIndexState } from './index/index.reducer'; -import { AuthState } from './auth/auth.reducer'; import { JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; import { MetaTagState } from './metadata/meta-tag.reducer'; import { RouteState } from './services/route.reducer'; -import { RequestState } from './data/request-state.model'; /** * The core sub-state in the NgRx store diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 1724e88743..5af2fe580a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,13 +1,13 @@ -import { ObjectCacheEffects } from './cache/object-cache.effects'; -import { UUIDIndexEffects } from './index/index.effects'; -import { RequestEffects } from './data/request.effects'; +import { MenuEffects } from '../shared/menu/menu.effects'; import { AuthEffects } from './auth/auth.effects'; -import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; +import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; -import { RouteEffects } from './services/route.effects'; +import { RequestEffects } from './data/request.effects'; +import { UUIDIndexEffects } from './index/index.effects'; +import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { RouterEffects } from './router/router.effects'; -import { MenuEffects } from '../shared/menu/menu.effects'; +import { RouteEffects } from './services/route.effects'; export const coreEffects = [ RequestEffects, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts deleted file mode 100644 index f151f10f66..0000000000 --- a/src/app/core/core.module.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; - -import { EffectsModule } from '@ngrx/effects'; - -import { Action, StoreConfig, StoreModule } from '@ngrx/store'; -import { MyDSpaceGuard } from '../my-dspace-page/my-dspace.guard'; - -import { isNotEmpty } from '../shared/empty.util'; -import { HostWindowService } from '../shared/host-window.service'; -import { MenuService } from '../shared/menu/menu.service'; -import { EndpointMockingRestService } from '../shared/mocks/dspace-rest/endpoint-mocking-rest.service'; -import { - MOCK_RESPONSE_MAP, - mockResponseMap, - ResponseMapMock -} from '../shared/mocks/dspace-rest/mocks/response-map.mock'; -import { NotificationsService } from '../shared/notifications/notifications.service'; -import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; -import { ObjectSelectService } from '../shared/object-select/object-select.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { AuthenticatedGuard } from './auth/authenticated.guard'; -import { AuthStatus } from './auth/models/auth-status.model'; -import { BrowseService } from './browse/browse.service'; -import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; -import { ObjectCacheService } from './cache/object-cache.service'; -import { SubmissionDefinitionsModel } from './config/models/config-submission-definitions.model'; -import { SubmissionFormsModel } from './config/models/config-submission-forms.model'; -import { SubmissionSectionModel } from './config/models/config-submission-section.model'; -import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; -import { SubmissionFormsConfigDataService } from './config/submission-forms-config-data.service'; -import { coreEffects } from './core.effects'; -import { coreReducers } from './core.reducers'; -import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; -import { CollectionDataService } from './data/collection-data.service'; -import { CommunityDataService } from './data/community-data.service'; -import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; -import { DebugResponseParsingService } from './data/debug-response-parsing.service'; -import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; -import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; -import { DSOResponseParsingService } from './data/dso-response-parsing.service'; -import { DSpaceObjectDataService } from './data/dspace-object-data.service'; -import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; -import { EntityTypeDataService } from './data/entity-type-data.service'; -import { ExternalSourceDataService } from './data/external-source-data.service'; -import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; -import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; -import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; -import { ItemDataService } from './data/item-data.service'; -import { LookupRelationService } from './data/lookup-relation.service'; -import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; -import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; -import { RelationshipTypeDataService } from './data/relationship-type-data.service'; -import { RelationshipDataService } from './data/relationship-data.service'; -import { ResourcePolicyDataService } from './resource-policy/resource-policy-data.service'; -import { SearchResponseParsingService } from './data/search-response-parsing.service'; -import { SiteDataService } from './data/site-data.service'; -import { DspaceRestService } from './dspace-rest/dspace-rest.service'; -import { EPersonDataService } from './eperson/eperson-data.service'; -import { EPerson } from './eperson/models/eperson.model'; -import { Group } from './eperson/models/group.model'; -import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; -import { MetadataField } from './metadata/metadata-field.model'; -import { MetadataSchema } from './metadata/metadata-schema.model'; -import { MetadataService } from './metadata/metadata.service'; -import { RegistryService } from './registry/registry.service'; -import { RoleService } from './roles/role.service'; -import { FeedbackDataService } from './feedback/feedback-data.service'; - -import { ServerResponseService } from './services/server-response.service'; -import { NativeWindowFactory, NativeWindowService } from './services/window.service'; -import { BitstreamFormat } from './shared/bitstream-format.model'; -import { Bitstream } from './shared/bitstream.model'; -import { BrowseDefinition } from './shared/browse-definition.model'; -import { BrowseEntry } from './shared/browse-entry.model'; -import { Bundle } from './shared/bundle.model'; -import { Collection } from './shared/collection.model'; -import { Community } from './shared/community.model'; -import { DSpaceObject } from './shared/dspace-object.model'; -import { ExternalSourceEntry } from './shared/external-source-entry.model'; -import { ExternalSource } from './shared/external-source.model'; -import { HALEndpointService } from './shared/hal-endpoint.service'; -import { ItemType } from './shared/item-relationships/item-type.model'; -import { RelationshipType } from './shared/item-relationships/relationship-type.model'; -import { Relationship } from './shared/item-relationships/relationship.model'; -import { Item } from './shared/item.model'; -import { License } from './shared/license.model'; -import { ResourcePolicy } from './resource-policy/models/resource-policy.model'; -import { SearchConfigurationService } from './shared/search/search-configuration.service'; -import { SearchFilterService } from './shared/search/search-filter.service'; -import { SearchService } from './shared/search/search.service'; -import { Site } from './shared/site.model'; -import { UUIDService } from './shared/uuid.service'; -import { WorkflowItem } from './submission/models/workflowitem.model'; -import { WorkspaceItem } from './submission/models/workspaceitem.model'; -import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; -import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; -import { SubmissionRestService } from './submission/submission-rest.service'; -import { WorkflowItemDataService } from './submission/workflowitem-data.service'; -import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; -import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; -import { ClaimedTask } from './tasks/models/claimed-task-object.model'; -import { PoolTask } from './tasks/models/pool-task-object.model'; -import { TaskObject } from './tasks/models/task-object.model'; -import { PoolTaskDataService } from './tasks/pool-task-data.service'; -import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; -import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; -import { BitstreamDataService } from './data/bitstream-data.service'; -import { environment } from '../../environments/environment'; -import { storeModuleConfig } from '../app.reducer'; -import { VersionDataService } from './data/version-data.service'; -import { VersionHistoryDataService } from './data/version-history-data.service'; -import { Version } from './shared/version.model'; -import { VersionHistory } from './shared/version-history.model'; -import { Script } from '../process-page/scripts/script.model'; -import { Process } from '../process-page/processes/process.model'; -import { ProcessDataService } from './data/processes/process-data.service'; -import { ScriptDataService } from './data/processes/script-data.service'; -import { WorkflowActionDataService } from './data/workflow-action-data.service'; -import { WorkflowAction } from './tasks/models/workflow-action-object.model'; -import { ItemTemplateDataService } from './data/item-template-data.service'; -import { TemplateItem } from './shared/template-item.model'; -import { Feature } from './shared/feature.model'; -import { Authorization } from './shared/authorization.model'; -import { FeatureDataService } from './data/feature-authorization/feature-data.service'; -import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; -import { - SiteAdministratorGuard -} from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; -import { Registration } from './shared/registration.model'; -import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; -import { MetadataFieldDataService } from './data/metadata-field-data.service'; -import { TokenResponseParsingService } from './auth/token-response-parsing.service'; -import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; -import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; -import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; -import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; -import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; -import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; -import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; -import { VocabularyService } from './submission/vocabularies/vocabulary.service'; -import { ConfigurationDataService } from './data/configuration-data.service'; -import { ConfigurationProperty } from './shared/configuration-property.model'; -import { ReloadGuard } from './reload/reload.guard'; -import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; -import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; -import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; -import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; -import { ShortLivedToken } from './auth/models/short-lived-token.model'; -import { UsageReport } from './statistics/models/usage-report.model'; -import { RootDataService } from './data/root-data.service'; -import { Root } from './data/root.model'; -import { SearchConfig } from './shared/search/search-filters/search-config.model'; -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'; -import { AccessStatusObject } from '../shared/object-collection/shared/badges/access-status-badge/access-status.model'; -import { AccessStatusDataService } from './data/access-status-data.service'; -import { LinkHeadService } from './services/link-head.service'; -import { ResearcherProfileDataService } from './profile/researcher-profile-data.service'; -import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; -import { ResearcherProfile } from './profile/model/researcher-profile.model'; -import { OrcidQueueDataService } from './orcid/orcid-queue-data.service'; -import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; -import { OrcidQueue } from './orcid/model/orcid-queue.model'; -import { OrcidHistory } from './orcid/model/orcid-history.model'; -import { OrcidAuthService } from './orcid/orcid-auth.service'; -import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; -import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; -import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; -import { Subscription } from '../shared/subscriptions/models/subscription.model'; -import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; -import { ItemRequest } from './shared/item-request.model'; -import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; -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 { SuggestionTarget } from './suggestion-notifications/models/suggestion-target.model'; -import { SuggestionSource } from './suggestion-notifications/models/suggestion-source.model'; - -/** - * When not in production, endpoint responses can be mocked for testing purposes - * If there is no mock version available for the endpoint, the actual REST response will be used just like in production mode - */ -export const restServiceFactory = (mocks: ResponseMapMock, http: HttpClient) => { - if (environment.production) { - return new DspaceRestService(http); - } else { - return new EndpointMockingRestService(mocks, http); - } -}; - -const IMPORTS = [ - CommonModule, - StoreModule.forFeature('core', coreReducers, storeModuleConfig as StoreConfig), - EffectsModule.forFeature(coreEffects) -]; - -const DECLARATIONS = []; - -const EXPORTS = []; - -const PROVIDERS = [ - AuthenticatedGuard, - CommunityDataService, - CollectionDataService, - SiteDataService, - DSOResponseParsingService, - { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, - EPersonDataService, - LinkHeadService, - HALEndpointService, - HostWindowService, - ItemDataService, - MetadataService, - ObjectCacheService, - PaginationComponentOptions, - ResourcePolicyDataService, - RegistryService, - BitstreamFormatDataService, - RemoteDataBuildService, - EndpointMapResponseParsingService, - FacetValueResponseParsingService, - FacetConfigResponseParsingService, - DebugResponseParsingService, - SearchResponseParsingService, - MyDSpaceResponseParsingService, - ServerResponseService, - BrowseService, - AccessStatusDataService, - SubmissionCcLicenseDataService, - SubmissionCcLicenseUrlDataService, - SubmissionFormsConfigDataService, - SubmissionRestService, - SubmissionResponseParsingService, - SubmissionJsonPatchOperationsService, - JsonPatchOperationsBuilder, - UUIDService, - NotificationsService, - WorkspaceitemDataService, - WorkflowItemDataService, - DSpaceObjectDataService, - ConfigurationDataService, - DSOChangeAnalyzer, - DefaultChangeAnalyzer, - ArrayMoveChangeAnalyzer, - ObjectSelectService, - MenuService, - ObjectUpdatesService, - SearchService, - RelationshipDataService, - MyDSpaceGuard, - RoleService, - TaskResponseParsingService, - ClaimedTaskDataService, - PoolTaskDataService, - BitstreamDataService, - EntityTypeDataService, - ContentSourceResponseParsingService, - ItemTemplateDataService, - SearchService, - SidebarService, - SearchFilterService, - SearchFilterService, - SearchConfigurationService, - SelectableListService, - RelationshipTypeDataService, - ExternalSourceDataService, - LookupRelationService, - VersionDataService, - VersionHistoryDataService, - WorkflowActionDataService, - ProcessDataService, - ScriptDataService, - FeatureDataService, - AuthorizationDataService, - SiteAdministratorGuard, - SiteRegisterGuard, - MetadataSchemaDataService, - MetadataFieldDataService, - TokenResponseParsingService, - ReloadGuard, - EndUserAgreementCurrentUserGuard, - EndUserAgreementCookieGuard, - EndUserAgreementService, - RootDataService, - NotificationsService, - FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory }, - VocabularyService, - VocabularyDataService, - VocabularyEntryDetailsDataService, - SequenceService, - GroupDataService, - FeedbackDataService, - ResearcherProfileDataService, - ProfileClaimService, - OrcidAuthService, - OrcidQueueDataService, - OrcidHistoryDataService, - SupervisionOrderDataService -]; - -/** - * Declaration needed to make sure all decorator functions are called in time - */ -export const models = - [ - Root, - DSpaceObject, - Bundle, - Bitstream, - BitstreamFormat, - Item, - Site, - Collection, - Community, - EPerson, - Group, - ResourcePolicy, - MetadataSchema, - MetadataField, - License, - WorkflowItem, - WorkspaceItem, - SubmissionCcLicence, - SubmissionCcLicenceUrl, - SubmissionDefinitionsModel, - SubmissionFormsModel, - SubmissionSectionModel, - SubmissionUploadsModel, - AuthStatus, - BrowseEntry, - BrowseDefinition, - NonHierarchicalBrowseDefinition, - FlatBrowseDefinition, - ValueListBrowseDefinition, - HierarchicalBrowseDefinition, - ClaimedTask, - TaskObject, - PoolTask, - Relationship, - RelationshipType, - ItemType, - ExternalSource, - ExternalSourceEntry, - Script, - Process, - Version, - VersionHistory, - WorkflowAction, - AdvancedWorkflowInfo, - RatingAdvancedWorkflowInfo, - SelectReviewerAdvancedWorkflowInfo, - TemplateItem, - Feature, - Authorization, - Registration, - Vocabulary, - VocabularyEntry, - VocabularyEntryDetail, - ConfigurationProperty, - ShortLivedToken, - Registration, - UsageReport, - QualityAssuranceTopicObject, - QualityAssuranceEventObject, - Root, - SearchConfig, - SubmissionAccessesModel, - QualityAssuranceSourceObject, - AccessStatusObject, - ResearcherProfile, - OrcidQueue, - OrcidHistory, - AccessStatusObject, - IdentifierData, - Subscription, - ItemRequest, - BulkAccessConditionOptions, - SuggestionTarget, - SuggestionSource - ]; - -@NgModule({ - imports: [ - ...IMPORTS - ], - declarations: [ - ...DECLARATIONS - ], - exports: [ - ...EXPORTS - ], - providers: [ - ...PROVIDERS - ] -}) - -export class CoreModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: CoreModule, - providers: [ - ...PROVIDERS - ] - }; - } - - constructor(@Optional() @SkipSelf() parentModule: CoreModule) { - if (isNotEmpty(parentModule)) { - throw new Error('CoreModule is already loaded. Import it in the AppModule only'); - } - } -} diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c0165c5384..fda1e05df0 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,19 +1,17 @@ -import { ActionReducerMap, } from '@ngrx/store'; +import { ActionReducerMap } from '@ngrx/store'; -import { objectCacheReducer } from './cache/object-cache.reducer'; -import { indexReducer } from './index/index.reducer'; -import { requestReducer } from './data/request.reducer'; +import { bitstreamFormatReducer } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { authReducer } from './auth/auth.reducer'; -import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; +import { objectCacheReducer } from './cache/object-cache.reducer'; import { serverSyncBufferReducer } from './cache/server-sync-buffer.reducer'; -import { objectUpdatesReducer } from './data/object-updates/object-updates.reducer'; -import { routeReducer } from './services/route.reducer'; -import { - bitstreamFormatReducer -} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; -import { historyReducer } from './history/history.reducer'; -import { metaTagReducer } from './metadata/meta-tag.reducer'; import { CoreState } from './core-state.model'; +import { objectUpdatesReducer } from './data/object-updates/object-updates.reducer'; +import { requestReducer } from './data/request.reducer'; +import { historyReducer } from './history/history.reducer'; +import { indexReducer } from './index/index.reducer'; +import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; +import { metaTagReducer } from './metadata/meta-tag.reducer'; +import { routeReducer } from './services/route.reducer'; export const coreReducers: ActionReducerMap = { 'bitstreamFormats': bitstreamFormatReducer, @@ -26,5 +24,5 @@ export const coreReducers: ActionReducerMap = { 'auth': authReducer, 'json/patch': jsonPatchOperationsReducer, 'metaTag': metaTagReducer, - 'route': routeReducer + 'route': routeReducer, }; diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts index 77c7974de2..899afb9be9 100644 --- a/src/app/core/core.selectors.ts +++ b/src/app/core/core.selectors.ts @@ -1,4 +1,5 @@ import { createFeatureSelector } from '@ngrx/store'; + import { CoreState } from './core-state.model'; /** diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts new file mode 100644 index 0000000000..8e76eef925 --- /dev/null +++ b/src/app/core/data-services-map.ts @@ -0,0 +1,139 @@ +import { LazyDataServicesMap } from '../../config/app-config.interface'; +import { + LDN_SERVICE, + LDN_SERVICE_CONSTRAINT_FILTERS, +} from '../admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type'; +import { ADMIN_NOTIFY_MESSAGE } from '../admin/admin-notify-dashboard/models/admin-notify-message.resource-type'; +import { NOTIFYREQUEST } from '../item-page/simple/notify-requests-status/notify-requests-status.resource-type'; +import { PROCESS } from '../process-page/processes/process.resource-type'; +import { SCRIPT } from '../process-page/scripts/script.resource-type'; +import { ACCESS_STATUS } from '../shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; +import { DUPLICATE } from '../shared/object-list/duplicate-data/duplicate.resource-type'; +import { IDENTIFIERS } from '../shared/object-list/identifier-data/identifier-data.resource-type'; +import { SUBSCRIPTION } from '../shared/subscriptions/models/subscription.resource-type'; +import { SUBMISSION_COAR_NOTIFY_CONFIG } from '../submission/sections/section-coar-notify/section-coar-notify-service.resource-type'; +import { SYSTEMWIDEALERT } from '../system-wide-alert/system-wide-alert.resource-type'; +import { + BULK_ACCESS_CONDITION_OPTIONS, + SUBMISSION_ACCESSES_TYPE, + SUBMISSION_FORMS_TYPE, + SUBMISSION_UPLOADS_TYPE, +} from './config/models/config-type'; +import { ROOT } from './data/root.resource-type'; +import { EPERSON } from './eperson/models/eperson.resource-type'; +import { GROUP } from './eperson/models/group.resource-type'; +import { WORKFLOWITEM } from './eperson/models/workflowitem.resource-type'; +import { WORKSPACEITEM } from './eperson/models/workspaceitem.resource-type'; +import { FEEDBACK } from './feedback/models/feedback.resource-type'; +import { METADATA_FIELD } from './metadata/metadata-field.resource-type'; +import { METADATA_SCHEMA } from './metadata/metadata-schema.resource-type'; +import { SUGGESTION } from './notifications/models/suggestion-objects.resource-type'; +import { SUGGESTION_SOURCE } from './notifications/models/suggestion-source-object.resource-type'; +import { SUGGESTION_TARGET } from './notifications/models/suggestion-target-object.resource-type'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from './notifications/qa/models/quality-assurance-event-object.resource-type'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from './notifications/qa/models/quality-assurance-source-object.resource-type'; +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from './notifications/qa/models/quality-assurance-topic-object.resource-type'; +import { ORCID_HISTORY } from './orcid/model/orcid-history.resource-type'; +import { ORCID_QUEUE } from './orcid/model/orcid-queue.resource-type'; +import { RESEARCHER_PROFILE } from './profile/model/researcher-profile.resource-type'; +import { RESOURCE_POLICY } from './resource-policy/models/resource-policy.resource-type'; +import { AUTHORIZATION } from './shared/authorization.resource-type'; +import { BITSTREAM } from './shared/bitstream.resource-type'; +import { BITSTREAM_FORMAT } from './shared/bitstream-format.resource-type'; +import { BROWSE_DEFINITION } from './shared/browse-definition.resource-type'; +import { BUNDLE } from './shared/bundle.resource-type'; +import { COLLECTION } from './shared/collection.resource-type'; +import { COMMUNITY } from './shared/community.resource-type'; +import { CONFIG_PROPERTY } from './shared/config-property.resource-type'; +import { DSPACE_OBJECT } from './shared/dspace-object.resource-type'; +import { FEATURE } from './shared/feature.resource-type'; +import { ITEM } from './shared/item.resource-type'; +import { ITEM_TYPE } from './shared/item-relationships/item-type.resource-type'; +import { RELATIONSHIP } from './shared/item-relationships/relationship.resource-type'; +import { RELATIONSHIP_TYPE } from './shared/item-relationships/relationship-type.resource-type'; +import { LICENSE } from './shared/license.resource-type'; +import { SITE } from './shared/site.resource-type'; +import { VERSION } from './shared/version.resource-type'; +import { VERSION_HISTORY } from './shared/version-history.resource-type'; +import { USAGE_REPORT } from './statistics/models/usage-report.resource-type'; +import { CorrectionType } from './submission/models/correctiontype.model'; +import { SUBMISSION_CC_LICENSE } from './submission/models/submission-cc-licence.resource-type'; +import { SUBMISSION_CC_LICENSE_URL } from './submission/models/submission-cc-licence-link.resource-type'; +import { + VOCABULARY, + VOCABULARY_ENTRY, + VOCABULARY_ENTRY_DETAIL, +} from './submission/vocabularies/models/vocabularies.resource-type'; +import { SUPERVISION_ORDER } from './supervision-order/models/supervision-order.resource-type'; +import { CLAIMED_TASK } from './tasks/models/claimed-task-object.resource-type'; +import { POOL_TASK } from './tasks/models/pool-task-object.resource-type'; +import { WORKFLOW_ACTION } from './tasks/models/workflow-action-object.resource-type'; + +export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ + [AUTHORIZATION.value, () => import('./data/feature-authorization/authorization-data.service').then(m => m.AuthorizationDataService)], + [BROWSE_DEFINITION.value, () => import('./browse/browse-definition-data.service').then(m => m.BrowseDefinitionDataService)], + [BULK_ACCESS_CONDITION_OPTIONS.value, () => import('./config/bulk-access-config-data.service').then(m => m.BulkAccessConfigDataService)], + [METADATA_SCHEMA.value, () => import('./data/metadata-schema-data.service').then(m => m.MetadataSchemaDataService)], + [SUBMISSION_UPLOADS_TYPE.value, () => import('./config/submission-uploads-config-data.service').then(m => m.SubmissionUploadsConfigDataService)], + [BITSTREAM.value, () => import('./data/bitstream-data.service').then(m => m.BitstreamDataService)], + [SUBMISSION_ACCESSES_TYPE.value, () => import('./config/submission-accesses-config-data.service').then(m => m.SubmissionAccessesConfigDataService)], + [SYSTEMWIDEALERT.value, () => import('./data/system-wide-alert-data.service').then(m => m.SystemWideAlertDataService)], + [USAGE_REPORT.value, () => import('./statistics/usage-report-data.service').then(m => m.UsageReportDataService)], + [ACCESS_STATUS.value, () => import('./data/access-status-data.service').then(m => m.AccessStatusDataService)], + [COLLECTION.value, () => import('./data/collection-data.service').then(m => m.CollectionDataService)], + [CLAIMED_TASK.value, () => import('./tasks/claimed-task-data.service').then(m => m.ClaimedTaskDataService)], + [VOCABULARY_ENTRY.value, () => import('./data/href-only-data.service').then(m => m.HrefOnlyDataService)], + [ITEM_TYPE.value, () => import('./data/href-only-data.service').then(m => m.HrefOnlyDataService)], + [LICENSE.value, () => import('./data/href-only-data.service').then(m => m.HrefOnlyDataService)], + [SUBSCRIPTION.value, () => import('../shared/subscriptions/subscriptions-data.service').then(m => m.SubscriptionsDataService)], + [COMMUNITY.value, () => import('./data/community-data.service').then(m => m.CommunityDataService)], + [VOCABULARY.value, () => import('./submission/vocabularies/vocabulary.data.service').then(m => m.VocabularyDataService)], + [BUNDLE.value, () => import('./data/bundle-data.service').then(m => m.BundleDataService)], + [CONFIG_PROPERTY.value, () => import('./data/configuration-data.service').then(m => m.ConfigurationDataService)], + [POOL_TASK.value, () => import('./tasks/pool-task-data.service').then(m => m.PoolTaskDataService)], + [CLAIMED_TASK.value, () => import('./tasks/claimed-task-data.service').then(m => m.ClaimedTaskDataService)], + [SUPERVISION_ORDER.value, () => import('./supervision-order/supervision-order-data.service').then(m => m.SupervisionOrderDataService)], + [WORKSPACEITEM.value, () => import('./submission/workspaceitem-data.service').then(m => m.WorkspaceitemDataService)], + [WORKFLOWITEM.value, () => import('./submission/workflowitem-data.service').then(m => m.WorkflowItemDataService)], + [VOCABULARY.value, () => import('./submission/vocabularies/vocabulary.data.service').then(m => m.VocabularyDataService)], + [VOCABULARY_ENTRY_DETAIL.value, () => import('./submission/vocabularies/vocabulary-entry-details.data.service').then(m => m.VocabularyEntryDetailsDataService)], + [SUBMISSION_CC_LICENSE_URL.value, () => import('./submission/submission-cc-license-url-data.service').then(m => m.SubmissionCcLicenseUrlDataService)], + [SUBMISSION_CC_LICENSE.value, () => import('./submission/submission-cc-license-data.service').then(m => m.SubmissionCcLicenseDataService)], + [USAGE_REPORT.value, () => import('./statistics/usage-report-data.service').then(m => m.UsageReportDataService)], + [RESOURCE_POLICY.value, () => import('./resource-policy/resource-policy-data.service').then(m => m.ResourcePolicyDataService)], + [RESEARCHER_PROFILE.value, () => import('./profile/researcher-profile-data.service').then(m => m.ResearcherProfileDataService)], + [ORCID_QUEUE.value, () => import('./orcid/orcid-queue-data.service').then(m => m.OrcidQueueDataService)], + [ORCID_HISTORY.value, () => import('./orcid/orcid-history-data.service').then(m => m.OrcidHistoryDataService)], + [FEEDBACK.value, () => import('./feedback/feedback-data.service').then(m => m.FeedbackDataService)], + [GROUP.value, () => import('./eperson/group-data.service').then(m => m.GroupDataService)], + [EPERSON.value, () => import('./eperson/eperson-data.service').then(m => m.EPersonDataService)], + [WORKFLOW_ACTION.value, () => import('./data/workflow-action-data.service').then(m => m.WorkflowActionDataService)], + [VERSION_HISTORY.value, () => import('./data/version-history-data.service').then(m => m.VersionHistoryDataService)], + [SITE.value, () => import('./data/site-data.service').then(m => m.SiteDataService)], + [ROOT.value, () => import('./data/root-data.service').then(m => m.RootDataService)], + [RELATIONSHIP_TYPE.value, () => import('./data/relationship-type-data.service').then(m => m.RelationshipTypeDataService)], + [RELATIONSHIP.value, () => import('./data/relationship-data.service').then(m => m.RelationshipDataService)], + [SCRIPT.value, () => import('./data/processes/script-data.service').then(m => m.ScriptDataService)], + [PROCESS.value, () => import('./data/processes/process-data.service').then(m => m.ProcessDataService)], + [METADATA_FIELD.value, () => import('./data/metadata-field-data.service').then(m => m.MetadataFieldDataService)], + [ITEM.value, () => import('./data/item-data.service').then(m => m.ItemDataService)], + [VERSION.value, () => import('./data/version-data.service').then(m => m.VersionDataService)], + [IDENTIFIERS.value, () => import('./data/identifier-data.service').then(m => m.IdentifierDataService)], + [FEATURE.value, () => import('./data/feature-authorization/authorization-data.service').then(m => m.AuthorizationDataService)], + [DSPACE_OBJECT.value, () => import('./data/dspace-object-data.service').then(m => m.DSpaceObjectDataService)], + [BITSTREAM_FORMAT.value, () => import('./data/bitstream-format-data.service').then(m => m.BitstreamFormatDataService)], + [SUBMISSION_COAR_NOTIFY_CONFIG.value, () => import('../submission/sections/section-coar-notify/coar-notify-config-data.service').then(m => m.CoarNotifyConfigDataService)], + [LDN_SERVICE_CONSTRAINT_FILTERS.value, () => import('../admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service').then(m => m.LdnItemfiltersService)], + [LDN_SERVICE.value, () => import('../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service').then(m => m.LdnServicesService)], + [ADMIN_NOTIFY_MESSAGE.value, () => import('../admin/admin-notify-dashboard/services/admin-notify-messages.service').then(m => m.AdminNotifyMessagesService)], + [SUBMISSION_FORMS_TYPE.value, () => import('./config/submission-forms-config-data.service').then(m => m.SubmissionFormsConfigDataService)], + [NOTIFYREQUEST.value, () => import('./data/notify-services-status-data.service').then(m => m.NotifyRequestsStatusDataService)], + [QUALITY_ASSURANCE_EVENT_OBJECT.value, () => import('./notifications/qa/events/quality-assurance-event-data.service').then(m => m.QualityAssuranceEventDataService)], + [QUALITY_ASSURANCE_SOURCE_OBJECT.value, () => import('./notifications/qa/source/quality-assurance-source-data.service').then(m => m.QualityAssuranceSourceDataService)], + [QUALITY_ASSURANCE_TOPIC_OBJECT.value, () => import('./notifications/qa/topics/quality-assurance-topic-data.service').then(m => m.QualityAssuranceTopicDataService)], + [SUGGESTION.value, () => import('./notifications/suggestions-data.service').then(m => m.SuggestionsDataService)], + [SUGGESTION_SOURCE.value, () => import('./notifications/source/suggestion-source-data.service').then(m => m.SuggestionSourceDataService)], + [SUGGESTION_TARGET.value, () => import('./notifications/target/suggestion-target-data.service').then(m => m.SuggestionTargetDataService)], + [DUPLICATE.value, () => import('./submission/submission-duplicate-data.service').then(m => m.SubmissionDuplicateDataService)], + [CorrectionType.type.value, () => import('./submission/correctiontype-data.service').then(m => m.CorrectionTypeDataService)], +]); diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts index 18b8cb5d65..1240585027 100644 --- a/src/app/core/data/access-status-data.service.spec.ts +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -1,17 +1,21 @@ -import { RequestService } from './request.service'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { Observable } from 'rxjs'; + +import { hasNoValue } from '../../shared/empty.util'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { GetRequest } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { hasNoValue } from '../../shared/empty.util'; -import { AccessStatusDataService } from './access-status-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Item } from '../shared/item.model'; +import { AccessStatusDataService } from './access-status-data.service'; +import { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; +import { RequestService } from './request.service'; const url = 'fake-url'; @@ -29,12 +33,12 @@ describe('AccessStatusDataService', () => { name: 'test-item', _links: { accessStatus: { - href: `https://rest.api/items/${itemId}/accessStatus` + href: `https://rest.api/items/${itemId}/accessStatus`, }, self: { - href: `https://rest.api/items/${itemId}` - } - } + href: `https://rest.api/items/${itemId}`, + }, + }, }); describe('when the requests are successful', () => { @@ -69,10 +73,10 @@ describe('AccessStatusDataService', () => { } rdbService = jasmine.createSpyObj('rdbService', { buildFromRequestUUID: buildResponse$, - buildSingle: buildResponse$ + buildSingle: buildResponse$, }); objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts index e8b77245e8..6d8acb1c8b 100644 --- a/src/app/core/data/access-status-data.service.ts +++ b/src/app/core/data/access-status-data.service.ts @@ -1,21 +1,19 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; + 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 { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; -import { ACCESS_STATUS } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; import { Item } from '../shared/item.model'; import { BaseDataService } from './base/base-data.service'; -import { dataService } from './base/data-service.decorator'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Data service responsible for retrieving the access status of Items */ -@Injectable() -@dataService(ACCESS_STATUS) +@Injectable({ providedIn: 'root' }) export class AccessStatusDataService extends BaseDataService { constructor( diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts index 025791d6dc..cf11decde4 100644 --- a/src/app/core/data/array-move-change-analyzer.service.spec.ts +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -1,7 +1,8 @@ -import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; import { moveItemInArray } from '@angular/cdk/drag-drop'; import { Operation } from 'fast-json-patch'; +import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; + /** * Helper class for creating move tests * Define a "from" and "to" index to move objects within the array before comparing @@ -28,7 +29,7 @@ describe('ArrayMoveChangeAnalyzer', () => { '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', 'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1', '0f608168-cdfc-46b0-92ce-889f7d3ac684', - '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + '546f9f5c-15dc-4eec-86fe-648007ac9e1c', ]; }); @@ -72,7 +73,7 @@ describe('ArrayMoveChangeAnalyzer', () => { '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', undefined, undefined, - '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + '546f9f5c-15dc-4eec-86fe-648007ac9e1c', ]; }); diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts index 36744e9f96..bd5fc8dedb 100644 --- a/src/app/core/data/array-move-change-analyzer.service.ts +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -1,12 +1,13 @@ -import { MoveOperation } from 'fast-json-patch'; -import { Injectable } from '@angular/core'; import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { Injectable } from '@angular/core'; +import { MoveOperation } from 'fast-json-patch'; + import { hasValue } from '../../shared/empty.util'; /** * A class to determine move operations between two arrays */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ArrayMoveChangeAnalyzer { /** diff --git a/src/app/core/data/base-response-parsing.service.spec.ts b/src/app/core/data/base-response-parsing.service.spec.ts index da9fa7a643..f6707d3582 100644 --- a/src/app/core/data/base-response-parsing.service.spec.ts +++ b/src/app/core/data/base-response-parsing.service.spec.ts @@ -1,9 +1,9 @@ /* eslint-disable max-classes-per-file */ -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GetRequest} from './request.models'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { GetRequest } from './request.models'; import { RestRequest } from './rest-request.model'; class TestService extends BaseResponseParsingService { @@ -35,7 +35,7 @@ describe('BaseResponseParsingService', () => { beforeEach(() => { obj = undefined; objectCache = jasmine.createSpyObj('objectCache', { - add: {} + add: {}, }); service = new TestService(objectCache); }); @@ -58,8 +58,8 @@ describe('BaseResponseParsingService', () => { beforeEach(() => { obj = Object.assign(new DSpaceObject(), { _links: { - self: { href: 'obj-selflink' } - } + self: { href: 'obj-selflink' }, + }, }); }); @@ -79,8 +79,8 @@ describe('BaseResponseParsingService', () => { data = { type: 'NotARealType', _links: { - self: { href: 'data-selflink' } - } + self: { href: 'data-selflink' }, + }, }; }); diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 18e6623683..63b4961b31 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -1,14 +1,21 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { getClassForType } from '../cache/builders/build-decorators'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { environment } from '../../../environments/environment'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { PageInfo } from '../shared/page-info.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; import { RestRequest } from './rest-request.model'; @@ -64,7 +71,7 @@ export abstract class BaseResponseParsingService { } else if (isRestDataObject(data._embedded[property])) { object[property] = this.retrieveObjectOrUrl(parsedObj); } else if (Array.isArray(parsedObj)) { - object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)); + object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)); } } }); @@ -96,14 +103,14 @@ export abstract class BaseResponseParsingService { list = this.flattenSingleKeyObject(list); } const page: ObjectDomain[] = this.processArray(list, request); - return buildPaginatedList(pageInfo, page,); + return buildPaginatedList(pageInfo, page); } protected processArray(data: any, request: RestRequest): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, request)]; - } + array = [...array, this.process(datum, request)]; + }, ); return array; } @@ -139,7 +146,7 @@ export abstract class BaseResponseParsingService { let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index 75662a691f..3f44ad5e5a 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -5,22 +5,37 @@ * * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { HALLink } from '../../shared/hal-link.model'; +import { FindListOptions } from '../find-list-options.model'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; const endpoint = 'https://rest.api/core'; @@ -46,38 +61,22 @@ describe('BaseDataService', () => { let requestService; let halService; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; let selfLink; let linksToFollow; let testScheduler; - let remoteDataMocks; + let remoteDataMocks: { [responseType: string]: RemoteData }; + let remoteDataPageMocks: { [responseType: string]: RemoteData }; function initTestService(): TestService { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - objectCache = { - - addPatch: () => { - /* empty */ - }, - getObjectBySelfLink: () => { - /* empty */ - }, - getByHref: () => { - /* empty */ - }, - addDependency: () => { - /* empty */ - }, - removeDependents: () => { - /* empty */ - }, - } as any; + objectCache = new ObjectCacheServiceStub(); selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -88,7 +87,27 @@ describe('BaseDataService', () => { const timeStamp = new Date().getTime(); const msToLive = 15 * 60 * 1000; - const payload = { foo: 'bar' }; + const payload = { + foo: 'bar', + followLink1: {}, + followLink2: {}, + _links: { + self: Object.assign(new HALLink(), { + href: 'self-test-link', + }), + followLink1: Object.assign(new HALLink(), { + href: 'follow-link-1', + }), + followLink2: [ + Object.assign(new HALLink(), { + href: 'follow-link-2-1', + }), + Object.assign(new HALLink(), { + href: 'follow-link-2-2', + }), + ], + }, + }; const statusCodeSuccess = 200; const statusCodeError = 404; const errorMessage = 'not found'; @@ -101,11 +120,20 @@ describe('BaseDataService', () => { Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; + remoteDataPageMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; return new TestService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } @@ -380,6 +408,27 @@ describe('BaseDataService', () => { }); + it('should link all the followLinks of a cached object by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + }; + + expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); describe(`findListByHref`, () => { @@ -392,8 +441,8 @@ describe('BaseDataService', () => { it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { testScheduler.run(({ cold }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); @@ -403,8 +452,8 @@ describe('BaseDataService', () => { it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); @@ -419,8 +468,8 @@ describe('BaseDataService', () => { it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); @@ -431,12 +480,12 @@ describe('BaseDataService', () => { it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData @@ -451,7 +500,7 @@ describe('BaseDataService', () => { it(`should return a the output from reRequestStaleRemoteData`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); const expected = 'a'; const values = { @@ -471,19 +520,19 @@ describe('BaseDataService', () => { it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = 'a-b-c-d-e'; const values = { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -493,20 +542,20 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { - a: remoteDataMocks.ResponsePendingStale, - b: remoteDataMocks.SuccessStale, - c: remoteDataMocks.ErrorStale, - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); const expected = '------d-e-f-g'; const values = { - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -525,18 +574,18 @@ describe('BaseDataService', () => { it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = '--b-c-d-e'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); @@ -546,20 +595,20 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { - a: remoteDataMocks.ResponsePendingStale, - b: remoteDataMocks.SuccessStale, - c: remoteDataMocks.ErrorStale, - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); const expected = '------d-e-f-g'; const values = { - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; @@ -567,6 +616,27 @@ describe('BaseDataService', () => { }); }); + it('should link all the followLinks of the cached objects by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', { + a: remoteDataPageMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + }; + + expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); }); @@ -576,8 +646,8 @@ describe('BaseDataService', () => { beforeEach(() => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ requestUUIDs: ['request1', 'request2', 'request3'], - dependentRequestUUIDs: ['request4', 'request5'] - })); + dependentRequestUUIDs: ['request4', 'request5'], + } as ObjectCacheEntry)); }); @@ -724,7 +794,7 @@ describe('BaseDataService', () => { (service as any).addDependency( createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }), - observableOf('dependsOnHref') + observableOf('dependsOnHref'), ); expect(addDependencySpy).toHaveBeenCalled(); }); @@ -739,7 +809,7 @@ describe('BaseDataService', () => { (service as any).addDependency( createFailedRemoteDataObject$('something went wrong'), - observableOf('dependsOnHref') + observableOf('dependsOnHref'), ); expect(addDependencySpy).toHaveBeenCalled(); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index 84b1686024..d09ee21ee0 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -6,24 +6,43 @@ * http://www.dspace.org/license/ */ -import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { + AsyncSubject, + from as observableFrom, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + mergeMap, + skipWhile, + switchMap, + take, + tap, + toArray, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; 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 { HALLink } from '../../shared/hal-link.model'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; import { GetRequest } from '../request.models'; import { RequestService } from '../request.service'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { FindListOptions } from '../find-list-options.model'; -import { PaginatedList } from '../paginated-list.model'; -import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; -import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; export const EMBED_SEPARATOR = '%2F'; /** @@ -235,7 +254,7 @@ export class BaseDataService implements HALDataServic if (hasValue(remoteData) && remoteData.isStale) { requestFn(); } - }) + }), ); } else { return source; @@ -268,7 +287,7 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + const response$: Observable> = this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a @@ -277,6 +296,25 @@ export class BaseDataService implements HALDataServic this.reRequestStaleRemoteData(reRequestOnStale, () => this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData) => { + if (hasValue(remoteDataObject?.payload?._links)) { + for (const followLinkName of Object.keys(remoteDataObject.payload._links)) { + // only add the followLinks if they are embedded + if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + }), + ); } /** @@ -302,7 +340,7 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + const response$: Observable>> = this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a @@ -311,6 +349,29 @@ export class BaseDataService implements HALDataServic this.reRequestStaleRemoteData(reRequestOnStale, () => this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData>) => { + if (hasValue(remoteDataObject?.payload?.page)) { + for (const object of remoteDataObject.payload.page) { + if (hasValue(object?._links)) { + for (const followLinkName of Object.keys(object._links)) { + // only add the followLinks if they are embedded + if (hasValue(object[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(object._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + } + } + }), + ); } /** @@ -329,7 +390,7 @@ export class BaseDataService implements HALDataServic href$.pipe( isNotEmptyOperator(), - take(1) + take(1), ).subscribe((href: string) => { const requestId = this.requestService.generateRequestId(); const request = new GetRequest(requestId, href); @@ -373,13 +434,13 @@ export class BaseDataService implements HALDataServic return this.hasCachedResponse(href$).pipe( switchMap((hasCachedResponse) => { if (hasCachedResponse) { - return this.rdbService.buildSingle(href$).pipe( - getFirstCompletedRemoteData(), - map((rd => rd.hasFailed)) - ); + return this.rdbService.buildSingle(href$).pipe( + getFirstCompletedRemoteData(), + map((rd => rd.hasFailed)), + ); } return observableOf(false); - }) + }), ); } @@ -420,7 +481,7 @@ export class BaseDataService implements HALDataServic } }), ), - dependsOnHref$ + dependsOnHref$, ); } @@ -437,7 +498,7 @@ export class BaseDataService implements HALDataServic switchMap((oce: ObjectCacheEntry) => { return observableFrom([ ...oce.requestUUIDs, - ...oce.dependentRequestUUIDs + ...oce.dependentRequestUUIDs, ]).pipe( mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), toArray(), diff --git a/src/app/core/data/base/create-data.spec.ts b/src/app/core/data/base/create-data.spec.ts index 0b2e0f3930..1248c5ffe4 100644 --- a/src/app/core/data/base/create-data.spec.ts +++ b/src/app/core/data/base/create-data.spec.ts @@ -5,23 +5,33 @@ * * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { RequestParam } from '../../cache/models/request-param.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceObject } from '../../shared/dspace-object.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { CreateData, CreateDataImpl } from './create-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { RequestParam } from '../../cache/models/request-param.model'; import { RestRequestMethod } from '../rest-request-method'; -import { DSpaceObject } from '../../shared/dspace-object.model'; +import { + CreateData, + CreateDataImpl, +} from './create-data'; /** * Tests whether calls to `CreateData` methods are correctly patched through in a concrete data service that implements it @@ -149,7 +159,7 @@ describe('CreateDataImpl', () => { describe('create', () => { it('should POST the object to the root endpoint with the given parameters and return the remote data', (done) => { const params = [ - new RequestParam('abc', 123), new RequestParam('def', 456) + new RequestParam('abc', 123), new RequestParam('def', 456), ]; buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts index d2e009f669..13216f8796 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -5,22 +5,31 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; -import { RequestParam } from '../../cache/models/request-param.model'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; -import { distinctUntilChanged, map, take, takeWhile } from 'rxjs/operators'; -import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; -import { getClassForType } from '../../cache/builders/build-decorators'; -import { CreateRequest } from '../request.models'; +import { + distinctUntilChanged, + map, + take, + takeWhile, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmptyOperator, +} from '../../../shared/empty.util'; import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getClassForType } from '../../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { RequestParam } from '../../cache/models/request-param.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { CreateRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can create objects. @@ -95,7 +104,7 @@ export class CreateDataImpl extends BaseDataService) => rd.isLoading, true) + takeWhile((rd: RemoteData) => rd.isLoading, true), ).subscribe((rd: RemoteData) => { if (rd.hasFailed) { this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); diff --git a/src/app/core/data/base/data-service.decorator.spec.ts b/src/app/core/data/base/data-service.decorator.spec.ts deleted file mode 100644 index e09c531a56..0000000000 --- a/src/app/core/data/base/data-service.decorator.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable max-classes-per-file */ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ -import { ResourceType } from '../../shared/resource-type'; -import { BaseDataService } from './base-data.service'; -import { HALDataService } from './hal-data-service.interface'; -import { dataService, getDataServiceFor } from './data-service.decorator'; -import { v4 as uuidv4 } from 'uuid'; - -class TestService extends BaseDataService { -} - -class AnotherTestService implements HALDataService { - public findListByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any { - return undefined; - } - - public findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any { - return undefined; - } -} - -let testType; - -describe('@dataService/getDataServiceFor', () => { - beforeEach(() => { - testType = new ResourceType(`testType-${uuidv4()}`); - }); - - it('should register a resourcetype for a dataservice', () => { - dataService(testType)(TestService); - expect(getDataServiceFor(testType)).toBe(TestService); - }); - - describe(`when the resource type isn't specified`, () => { - it(`should throw an error`, () => { - expect(() => { - dataService(undefined)(TestService); - }).toThrow(); - }); - }); - - describe(`when there already is a registered dataservice for a resourcetype`, () => { - it(`should throw an error`, () => { - dataService(testType)(TestService); - expect(() => { - dataService(testType)(AnotherTestService); - }).toThrow(); - }); - }); -}); diff --git a/src/app/core/data/base/data-service.decorator.ts b/src/app/core/data/base/data-service.decorator.ts deleted file mode 100644 index fbde9bd94f..0000000000 --- a/src/app/core/data/base/data-service.decorator.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ -import { InjectionToken } from '@angular/core'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { ResourceType } from '../../shared/resource-type'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { HALDataService } from './hal-data-service.interface'; - -export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>>('getDataServiceFor', { - providedIn: 'root', - factory: () => getDataServiceFor, -}); -const dataServiceMap = new Map(); - -/** - * A class decorator to indicate that this class is a data service for a given HAL resource type. - * - * In most cases, a data service should extend {@link BaseDataService}. - * At the very least it must implement {@link HALDataService} in order for it to work with {@link LinkService}. - * - * @param resourceType the resource type the class is a dataservice for - */ -export function dataService(resourceType: ResourceType) { - return (target: GenericConstructor>): void => { - 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): GenericConstructor> { - return dataServiceMap.get(resourceType.value); -} diff --git a/src/app/core/data/base/delete-data.spec.ts b/src/app/core/data/base/delete-data.spec.ts index a076473b0f..53d651402f 100644 --- a/src/app/core/data/base/delete-data.spec.ts +++ b/src/app/core/data/base/delete-data.spec.ts @@ -5,24 +5,35 @@ * * http://www.dspace.org/license/ */ -import { constructIdEndpointDefault } from './identifiable-data.service'; -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { DeleteData, DeleteDataImpl } from './delete-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { RestRequestMethod } from '../rest-request-method'; +import { + DeleteData, + DeleteDataImpl, +} from './delete-data'; +import { constructIdEndpointDefault } from './identifiable-data.service'; /** * Tests whether calls to `DeleteData` methods are correctly patched through in a concrete data service that implements it @@ -34,7 +45,7 @@ export function testDeleteDataImplementation(serviceFactory: () => DeleteData { @@ -105,13 +116,13 @@ describe('DeleteDataImpl', () => { }, getByHref: () => { /* empty */ - } + }, } as any; notificationsService = {} as NotificationsService; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { diff --git a/src/app/core/data/base/delete-data.ts b/src/app/core/data/base/delete-data.ts index 807d9d838e..e758ee40fa 100644 --- a/src/app/core/data/base/delete-data.ts +++ b/src/app/core/data/base/delete-data.ts @@ -5,19 +5,26 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { NoContent } from '../../shared/NoContent.model'; import { switchMap } from 'rxjs/operators'; -import { DeleteRequest } from '../request.models'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NoContent } from '../../shared/NoContent.model'; +import { RemoteData } from '../remote-data'; +import { DeleteRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './identifiable-data.service'; export interface DeleteData { /** diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts index 6a73e032d0..f2c48feb76 100644 --- a/src/app/core/data/base/find-all-data.spec.ts +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -5,24 +5,33 @@ * * http://www.dspace.org/license/ */ -import { FindAllData, FindAllDataImpl } from './find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; -import { RequestParam } from '../../cache/models/request-param.model'; - -import { RequestService } from '../request.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { + SortDirection, + SortOptions, +} from '../../cache/models/sort-options.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { EMBED_SEPARATOR } from './base-data.service'; +import { + FindAllData, + FindAllDataImpl, +} from './find-all-data'; /** * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it @@ -143,8 +152,8 @@ describe('FindAllDataImpl', () => { options = {}; (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(endpoint); - }, + expect(value).toBe(endpoint); + }, ); }); diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts index bc0c1fb613..0d68689e61 100644 --- a/src/app/core/data/base/find-all-data.ts +++ b/src/app/core/data/base/find-all-data.ts @@ -7,18 +7,23 @@ */ import { Observable } from 'rxjs'; -import { FindListOptions } from '../find-list-options.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, +} from 'rxjs/operators'; + import { isNotEmpty } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that list all of its objects. @@ -87,10 +92,9 @@ export class FindAllDataImpl extends BaseDataService< * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { - let endpoint$: Observable; const args = []; - endpoint$ = this.getBrowseEndpoint(options).pipe( + const endpoint$ = this.getBrowseEndpoint(options).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), distinctUntilChanged(), diff --git a/src/app/core/data/base/hal-data-service.interface.ts b/src/app/core/data/base/hal-data-service.interface.ts index 6959399760..1ffdffaa7c 100644 --- a/src/app/core/data/base/hal-data-service.interface.ts +++ b/src/app/core/data/base/hal-data-service.interface.ts @@ -6,11 +6,12 @@ * http://www.dspace.org/license/ */ import { Observable } from 'rxjs'; + import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; +import { HALResource } from '../../shared/hal-resource.model'; import { FindListOptions } from '../find-list-options.model'; import { PaginatedList } from '../paginated-list.model'; -import { HALResource } from '../../shared/hal-resource.model'; +import { RemoteData } from '../remote-data'; /** * An interface defining the minimum functionality needed for a data service to resolve HAL resources. diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts index 11af83ff9f..528d6c4945 100644 --- a/src/app/core/data/base/identifiable-data.service.spec.ts +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -5,21 +5,25 @@ * * http://www.dspace.org/license/ */ -import { FindListOptions } from '../find-list-options.model'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { IdentifiableDataService } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { EMBED_SEPARATOR } from './base-data.service'; +import { IdentifiableDataService } from './identifiable-data.service'; const endpoint = 'https://rest.api/core'; @@ -63,12 +67,12 @@ describe('IdentifiableDataService', () => { }, getByHref: () => { /* empty */ - } + }, } as any; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -132,7 +136,7 @@ describe('IdentifiableDataService', () => { resourceIdMock, followLink('bundles', { shouldEmbed: false }), followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') + followLink('templateItemOf'), ); expect(result).toEqual(expected); }); diff --git a/src/app/core/data/base/identifiable-data.service.ts b/src/app/core/data/base/identifiable-data.service.ts index 904f925765..da3167903e 100644 --- a/src/app/core/data/base/identifiable-data.service.ts +++ b/src/app/core/data/base/identifiable-data.service.ts @@ -5,16 +5,17 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { RemoteData } from '../remote-data'; -import { BaseDataService } from './base-data.service'; -import { RequestService } from '../request.service'; + +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Shorthand type for the method to construct an ID endpoint. diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts index a55b1229b8..03c15199a7 100644 --- a/src/app/core/data/base/patch-data.spec.ts +++ b/src/app/core/data/base/patch-data.spec.ts @@ -6,28 +6,38 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { RequestService } from '../request.service'; +import { + compare, + Operation, +} from 'fast-json-patch'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { PatchData, PatchDataImpl } from './patch-data'; -import { ChangeAnalyzer } from '../change-analyzer'; -import { Item } from '../../shared/item.model'; -import { compare, Operation } from 'fast-json-patch'; -import { PatchRequest } from '../request.models'; import { DSpaceObject } from '../../shared/dspace-object.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { constructIdEndpointDefault } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { Item } from '../../shared/item.model'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { PatchRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { RestRequestMethod } from '../rest-request-method'; +import { constructIdEndpointDefault } from './identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './patch-data'; /** * Tests whether calls to `PatchData` methods are correctly patched through in a concrete data service that implements it @@ -182,15 +192,15 @@ describe('PatchDataImpl', () => { _links: { self: { href: 'dso-href', - } - } + }, + }, }; const operations = [ Object.assign({ op: 'move', from: '/1', - path: '/5' - }) as Operation + path: '/5', + }) as Operation, ]; it('should send a PatchRequest', () => { @@ -224,12 +234,12 @@ describe('PatchDataImpl', () => { dso = Object.assign(new DSpaceObject(), { _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name1 }] + metadata: [{ key: 'dc.title', value: name1 }], }); dso2 = Object.assign(new DSpaceObject(), { _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name2 }] + metadata: [{ key: 'dc.title', value: name2 }], }); spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index 1f93671458..adcf98ef94 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -5,22 +5,36 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { find, map, mergeMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { PatchRequest } from '../request.models'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators'; -import { ChangeAnalyzer } from '../change-analyzer'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { RestRequestMethod } from '../rest-request-method'; -import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; +import { + find, + map, + mergeMap, +} from 'rxjs/operators'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../shared/operators'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { RemoteData } from '../remote-data'; +import { PatchRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { RestRequestMethod } from '../rest-request-method'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './identifiable-data.service'; /** * Interface for a data service that can patch and update objects. diff --git a/src/app/core/data/base/put-data.spec.ts b/src/app/core/data/base/put-data.spec.ts index 6287fe91b1..1430bb3106 100644 --- a/src/app/core/data/base/put-data.spec.ts +++ b/src/app/core/data/base/put-data.spec.ts @@ -6,20 +6,27 @@ * http://www.dspace.org/license/ */ -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 { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { PutData, PutDataImpl } from './put-data'; -import { RestRequestMethod } from '../rest-request-method'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { DSpaceObject } from '../../shared/dspace-object.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; +import { RestRequestMethod } from '../rest-request-method'; +import { + PutData, + PutDataImpl, +} from './put-data'; /** * Tests whether calls to `PutData` methods are correctly patched through in a concrete data service that implements it @@ -127,7 +134,7 @@ describe('PutDataImpl', () => { metadata: { // recognized properties will be serialized ['dc.title']: [ { language: 'en', value: 'some object' }, - ] + ], }, data: [ 1, 2, 3, 4 ], // unrecognized properties won't be serialized _links: { self: { href: selfLink } }, @@ -144,7 +151,7 @@ describe('PutDataImpl', () => { method: RestRequestMethod.PUT, body: { // _links are not serialized uuid: obj.uuid, - metadata: obj.metadata + metadata: obj.metadata, }, })); done(); diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts index 66ae73405e..e9d2b01eb8 100644 --- a/src/app/core/data/base/put-data.ts +++ b/src/app/core/data/base/put-data.ts @@ -5,18 +5,19 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; + +import { hasValue } from '../../../shared/empty.util'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { PutRequest } from '../request.models'; -import { hasValue } from '../../../shared/empty.util'; -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 { RemoteData } from '../remote-data'; +import { PutRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can send PUT requests. @@ -55,7 +56,7 @@ export class PutDataImpl extends BaseDataService i */ put(object: T): Observable> { const requestId = this.requestService.generateRequestId(); - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor).serialize(object); const request = new PutRequest(requestId, object._links.self.href, serializedObject); if (hasValue(this.responseMsToLive)) { diff --git a/src/app/core/data/base/search-data.spec.ts b/src/app/core/data/base/search-data.spec.ts index 31dddeddfc..af9f87bf2c 100644 --- a/src/app/core/data/base/search-data.spec.ts +++ b/src/app/core/data/base/search-data.spec.ts @@ -5,12 +5,17 @@ * * http://www.dspace.org/license/ */ -import { constructSearchEndpointDefault, SearchData, SearchDataImpl } from './search-data'; -import { FindListOptions } from '../find-list-options.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; import { of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; + import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../find-list-options.model'; +import { + constructSearchEndpointDefault, + SearchData, + SearchDataImpl, +} from './search-data'; /** * Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts index ff0b492945..f758affa28 100644 --- a/src/app/core/data/base/search-data.ts +++ b/src/app/core/data/base/search-data.ts @@ -5,19 +5,26 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; -import { hasNoValue, isNotEmpty } from '../../../shared/empty.util'; -import { FindListOptions } from '../find-list-options.model'; +import { + filter, + map, +} from 'rxjs/operators'; + +import { + hasNoValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Shorthand type for method to construct a search endpoint @@ -112,10 +119,9 @@ export class SearchDataImpl extends BaseDataService[]): Observable { - let result$: Observable; const args = []; - result$ = this.getSearchEndpoint(searchMethod); + const result$ = this.getSearchEndpoint(searchMethod); return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index ccdff75fdb..95fe5f593f 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -1,31 +1,41 @@ import { TestBed } from '@angular/core/testing'; -import { BitstreamDataService } from './bitstream-data.service'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { ItemMock } from 'src/app/shared/mocks/item.mock'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from 'src/app/shared/remote-data.utils'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { RequestService } from './request.service'; import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { Observable, of as observableOf } from 'rxjs'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; -import { PatchRequest, PutRequest } from './request.models'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; -import { testDeleteDataImplementation } from './base/delete-data.spec'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import objectContaining = jasmine.objectContaining; -import { RemoteData } from './remote-data'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { BundleDataService } from './bundle-data.service'; -import { ItemMock } from 'src/app/shared/mocks/item.mock'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; import { Bundle } from '../shared/bundle.model'; -import { cold } from 'jasmine-marbles'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { BitstreamDataService } from './bitstream-data.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BundleDataService } from './bundle-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { RemoteData } from './remote-data'; +import { + PatchRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import objectContaining = jasmine.objectContaining; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -41,32 +51,32 @@ describe('BitstreamDataService', () => { id: 'fake-bitstream1', uuid: 'fake-bitstream1', _links: { - self: { href: 'fake-bitstream1-self' } - } + self: { href: 'fake-bitstream1-self' }, + }, }); const bitstream2 = Object.assign(new Bitstream(), { id: 'fake-bitstream2', uuid: 'fake-bitstream2', _links: { - self: { href: 'fake-bitstream2-self' } - } + self: { href: 'fake-bitstream2-self' }, + }, }); const format = Object.assign(new BitstreamFormat(), { id: '2', shortDescription: 'PNG', description: 'Portable Network Graphics', - supportLevel: BitstreamFormatSupportLevel.Known + supportLevel: BitstreamFormatSupportLevel.Known, }); const url = 'fake-bitstream-url'; beforeEach(() => { objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); requestService = getMockRequestService(); halService = Object.assign(new HALEndpointServiceStub(url)); bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', { - getBrowseEndpoint: observableOf(bitstreamFormatHref) + getBrowseEndpoint: observableOf(bitstreamFormatHref), }); rdbService = getMockRemoteDataBuildService(); @@ -128,7 +138,7 @@ describe('BitstreamDataService', () => { describe('findPrimaryBitstreamByItemAndName', () => { it('should return primary bitstream', () => { - const exprected$ = cold('(a|)', { a: bitstream1} ); + const exprected$ = cold('(a|)', { a: bitstream1 } ); const bundle = Object.assign(new Bundle(), { primaryBitstream: observableOf(createSuccessfulRemoteDataObject(bitstream1)), }); @@ -137,7 +147,7 @@ describe('BitstreamDataService', () => { }); it('should return null if primary bitstream has not be succeeded ', () => { - const exprected$ = cold('(a|)', { a: null} ); + const exprected$ = cold('(a|)', { a: null } ); const bundle = Object.assign(new Bundle(), { primaryBitstream: observableOf(createFailedRemoteDataObject()), }); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 97949ffa25..81d1d74535 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,48 +1,74 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable, EMPTY } from 'rxjs'; -import { find, map, switchMap, take } from 'rxjs/operators'; +import { + Operation, + RemoveOperation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + EMPTY, + Observable, +} from 'rxjs'; +import { + find, + map, + switchMap, + take, +} from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; -import { FollowLinkConfig, followLink } from '../../shared/utils/follow-link-config.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + followLink, + 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 { ObjectCacheService } from '../cache/object-cache.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Bitstream } from '../shared/bitstream.model'; -import { BITSTREAM } from '../shared/bitstream.resource-type'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; import { Bundle } from '../shared/bundle.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { BundleDataService } from './bundle-data.service'; -import { buildPaginatedList, PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { PatchRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { PageInfo } from '../shared/page-info.model'; -import { RequestParam } from '../cache/models/request-param.model'; -import { sendRequest } from '../shared/request.operators'; -import { FindListOptions } from './find-list-options.model'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RestRequestMethod } from './rest-request-method'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NoContent } from '../shared/NoContent.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; -import { Operation, RemoveOperation } from 'fast-json-patch'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { PageInfo } from '../shared/page-info.model'; +import { sendRequest } from '../shared/request.operators'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BundleDataService } from './bundle-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + PatchRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import { RestRequestMethod } from './rest-request-method'; /** * A service to retrieve {@link Bitstream}s from the REST API */ -@Injectable({ - providedIn: 'root', -}) -@dataService(BITSTREAM) +@Injectable({ providedIn: 'root' }) export class BitstreamDataService extends IdentifiableDataService implements SearchData, PatchData, DeleteData { private searchData: SearchDataImpl; private patchData: PatchDataImpl; @@ -108,7 +134,7 @@ export class BitstreamDataService extends IdentifiableDataService imp } else { return [bundleRD as any]; } - }) + }), ); } @@ -121,10 +147,10 @@ export class BitstreamDataService extends IdentifiableDataService imp const requestId = this.requestService.generateRequestId(); const bitstreamHref$ = this.getBrowseEndpoint().pipe( map((href: string) => `${href}/${bitstream.id}`), - switchMap((href: string) => this.halService.getEndpoint('format', href)) + switchMap((href: string) => this.halService.getEndpoint('format', href)), ); const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe( - map((href: string) => `${href}/${format.id}`) + map((href: string) => `${href}/${format.id}`), ); observableCombineLatest([bitstreamHref$, formatHref$]).pipe( map(([bitstreamHref, formatHref]) => { @@ -135,7 +161,7 @@ export class BitstreamDataService extends IdentifiableDataService imp return new PutRequest(requestId, bitstreamHref, formatHref, options); }), sendRequest(this.requestService), - take(1) + take(1), ).subscribe(() => { this.requestService.removeByHrefSubstring(bitstream.self + '/format'); }); @@ -178,7 +204,7 @@ export class BitstreamDataService extends IdentifiableDataService imp const hrefObs = this.getSearchByHref( 'byItemHandle', { searchParams }, - ...linksToFollow + ...linksToFollow, ); return this.findByHref( @@ -227,9 +253,9 @@ export class BitstreamDataService extends IdentifiableDataService imp } return rd.payload.primaryBitstream.pipe( getFirstCompletedRemoteData(), - map((rdb: RemoteData) => rdb.hasSucceeded ? rdb.payload : null) + map((rdb: RemoteData) => rdb.hasSucceeded ? rdb.payload : null), ); - }) + }), ); } diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index 15efebe8c7..234326d453 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -1,21 +1,36 @@ -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { RestResponse } from '../cache/response.models'; -import { Observable, of as observableOf } from 'rxjs'; -import { Action, Store } from '@ngrx/store'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; import { waitForAsync } from '@angular/core/testing'; -import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { + Action, + Store, +} from '@ngrx/store'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction, +} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { RequestEntry } from './request-entry.model'; describe('BitstreamFormatDataService', () => { let service: BitstreamFormatDataService; @@ -31,19 +46,19 @@ describe('BitstreamFormatDataService', () => { const store = { dispatch(action: Action) { // Do Nothing - } + }, } as Store; const requestUUIDs = ['some', 'uuid']; const objectCache = jasmine.createSpyObj('objectCache', { - getByHref: observableOf({ requestUUIDs }) + getByHref: observableOf({ requestUUIDs }), }) as ObjectCacheService; const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: bitstreamFormatsEndpoint }); - } + }, } as HALEndpointService; const notificationsService = {} as NotificationsService; @@ -83,7 +98,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -104,7 +119,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -127,7 +142,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -149,7 +164,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -174,7 +189,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -198,12 +213,12 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); const halService = { getEndpoint(linkPath: string): Observable { return observableOf(bitstreamFormatsEndpoint); - } + }, } as HALEndpointService; service = initTestService(halService); service.clearBitStreamFormatRequests().subscribe(); @@ -222,7 +237,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -245,7 +260,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -268,7 +283,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -289,12 +304,12 @@ describe('BitstreamFormatDataService', () => { getByUUID: hot('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); const halService = { getEndpoint(linkPath: string): Observable { return observableOf(bitstreamFormatsEndpoint); - } + }, } as HALEndpointService; service = initTestService(halService); })); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 0104389815..97b0fa961a 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,30 +1,50 @@ import { Injectable } from '@angular/core'; -import { createSelector, select, Store } from '@ngrx/store'; +import { + createSelector, + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { + distinctUntilChanged, + map, + tap, +} from 'rxjs/operators'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; + +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction, +} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { coreSelector } from '../core.selectors'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; -import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteData } from './remote-data'; -import { PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { sendRequest } from '../shared/request.operators'; import { CoreState } from '../core-state.model'; +import { Bitstream } from '../shared/bitstream.model'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; +import { sendRequest } from '../shared/request.operators'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; -import { NoContent } from '../shared/NoContent.model'; -import { dataService } from './base/data-service.decorator'; +import { RemoteData } from './remote-data'; +import { + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; const bitstreamFormatsStateSelector = createSelector( coreSelector, @@ -38,8 +58,7 @@ const selectedBitstreamFormatSelector = createSelector( /** * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint */ -@Injectable() -@dataService(BITSTREAM_FORMAT) +@Injectable({ providedIn: 'root' }) export class BitstreamFormatDataService extends IdentifiableDataService implements FindAllData, DeleteData { protected linkPath = 'bitstreamformats'; @@ -106,7 +125,7 @@ export class BitstreamFormatDataService extends IdentifiableDataService { return new PostRequest(requestId, endpointURL, bitstreamFormat); }), - sendRequest(this.requestService) + sendRequest(this.requestService), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); @@ -117,7 +136,7 @@ export class BitstreamFormatDataService extends IdentifiableDataService { return this.getBrowseEndpoint().pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) + tap((href: string) => this.requestService.removeByHrefSubstring(href)), ); } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 9fa7239ef7..53d2ec20fc 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,9 +1,9 @@ import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; -import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; class TestService extends BrowseResponseParsingService { constructor(protected objectCache: ObjectCacheService) { @@ -26,22 +26,22 @@ describe('BrowseResponseParsingService', () => { describe('', () => { const mockFlatBrowse = { - id: 'title', - browseType: 'flatBrowse', - type: 'browse', - }; + id: 'title', + browseType: 'flatBrowse', + type: 'browse', + }; const mockValueList = { - id: 'author', - browseType: 'valueList', - type: 'browse', - }; + id: 'author', + browseType: 'valueList', + type: 'browse', + }; const mockHierarchicalBrowse = { - id: 'srsc', - browseType: 'hierarchicalBrowse', - type: 'browse', - }; + id: 'srsc', + browseType: 'hierarchicalBrowse', + type: 'browse', + }; it('should deserialize flatBrowses correctly', () => { let deserialized = service.deserialize(mockFlatBrowse); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index a568cdb617..e01fa17f1f 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -1,18 +1,17 @@ import { Injectable } from '@angular/core'; -import { ObjectCacheService } from '../cache/object-cache.service'; + import { hasValue } from '../../shared/empty.util'; -import { - HIERARCHICAL_BROWSE_DEFINITION -} from '../shared/hierarchical-browse-definition.resource-type'; -import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; -import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; -import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; -import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Serializer } from '../serializer'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; /** * A ResponseParsingService used to parse a REST API response to a BrowseDefinition object diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index e3ba438f9b..b2c8be06af 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -1,19 +1,23 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { compare, Operation } from 'fast-json-patch'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Item } from '../shared/item.model'; -import { ChangeAnalyzer } from './change-analyzer'; +import { + compare, + Operation, +} from 'fast-json-patch'; + import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { BundleDataService } from './bundle-data.service'; -import { HALLink } from '../shared/hal-link.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; -import { Bundle } from '../shared/bundle.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core-state.model'; +import { Bundle } from '../shared/bundle.model'; +import { HALLink } from '../shared/hal-link.model'; +import { Item } from '../shared/item.model'; import { testPatchDataImplementation } from './base/patch-data.spec'; +import { BundleDataService } from './bundle-data.service'; +import { ChangeAnalyzer } from './change-analyzer'; class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { @@ -41,7 +45,7 @@ describe('BundleDataService', () => { bundleHALLink.href = bundleLink; item = new Item(); item._links = { - bundles: bundleHALLink + bundles: bundleHALLink, }; requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; @@ -56,7 +60,7 @@ describe('BundleDataService', () => { }, getObjectBySelfLink: () => { /* empty */ - } + }, } as any; store = {} as Store; return new BundleDataService( @@ -99,30 +103,30 @@ describe('BundleDataService', () => { metadata: { 'dc.title': [ { - value: 'ORIGINAL' - } - ] - } + value: 'ORIGINAL', + }, + ], + }, }), Object.assign(new Bundle(), { id: 'THUMBNAIL_BUNDLE', metadata: { 'dc.title': [ { - value: 'THUMBNAIL' - } - ] - } + value: 'THUMBNAIL', + }, + ], + }, }), Object.assign(new Bundle(), { id: 'EXTRA_BUNDLE', metadata: { 'dc.title': [ { - value: 'EXTRA' - } - ] - } + value: 'EXTRA', + }, + ], + }, }), ]; spyOn(service, 'findAllByItem').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(bundles))); diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 19f0e73706..5d552c9bf0 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,36 +1,39 @@ import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; -import { BUNDLE } from '../shared/bundle.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Bitstream } from '../shared/bitstream.model'; import { RequestEntryState } from './request-entry-state.model'; -import { FindListOptions } from './find-list-options.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RestRequestMethod } from './rest-request-method'; -import { Operation } from 'fast-json-patch'; -import { dataService } from './base/data-service.decorator'; /** * A service to retrieve {@link Bundle}s from the REST API */ -@Injectable( - { providedIn: 'root' }, -) -@dataService(BUNDLE) +@Injectable({ providedIn: 'root' }) export class BundleDataService extends IdentifiableDataService implements PatchData { private bitstreamsEndpoint = 'bitstreams'; @@ -91,7 +94,7 @@ export class BundleDataService extends IdentifiableDataService implement RequestEntryState.Success, null, matchingBundle, - 200 + 200, ); } else { return new RemoteData( @@ -101,7 +104,7 @@ export class BundleDataService extends IdentifiableDataService implement RequestEntryState.Error, `The bundle with name ${bundleName} was not found.`, null, - 404 + 404, ); } } else { @@ -119,7 +122,7 @@ export class BundleDataService extends IdentifiableDataService implement getBitstreamsEndpoint(bundleId: string, searchOptions?: PaginatedSearchOptions): Observable { return this.getBrowseEndpoint().pipe( switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)), - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href), ); } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 65f8b3ab2c..431fe941bb 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -1,29 +1,45 @@ -import { CollectionDataService } from './collection-data.service'; -import { RequestService } from './request.service'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; import { TranslateService } from '@ngx-translate/core'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { Observable } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { hasNoValue } from '../../shared/empty.util'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { ContentSourceRequest, UpdateContentSourceRequest } from './request.models'; -import { ContentSource } from '../shared/content-source.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Collection } from '../shared/collection.model'; +import { ContentSource } from '../shared/content-source.model'; import { PageInfo } from '../shared/page-info.model'; -import { buildPaginatedList } from './paginated-list.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { hasNoValue } from '../../shared/empty.util'; import { testCreateDataImplementation } from './base/create-data.spec'; -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 { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { CollectionDataService } from './collection-data.service'; +import { buildPaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + ContentSourceRequest, + UpdateContentSourceRequest, +} from './request.models'; +import { RequestService } from './request.service'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -35,7 +51,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(), { @@ -43,9 +59,9 @@ describe('CollectionDataService', () => { name: 'test-collection-1', _links: { self: { - href: 'https://rest.api/collections/test-collection-1-1' - } - } + href: 'https://rest.api/collections/test-collection-1-1', + }, + }, }); const mockCollection2: Collection = Object.assign(new Collection(), { @@ -53,9 +69,9 @@ describe('CollectionDataService', () => { name: 'test-collection-2', _links: { self: { - href: 'https://rest.api/collections/test-collection-2-2' - } - } + href: 'https://rest.api/collections/test-collection-2-2', + }, + }, }); const mockCollection3: Collection = Object.assign(new Collection(), { @@ -63,9 +79,9 @@ describe('CollectionDataService', () => { name: 'test-collection-3', _links: { self: { - href: 'https://rest.api/collections/test-collection-3-3' - } - } + href: 'https://rest.api/collections/test-collection-3-3', + }, + }, }); const queryString = 'test-string'; @@ -138,7 +154,7 @@ describe('CollectionDataService', () => { it('should return a RemoteData> for the getAuthorizedCollection', () => { const result = service.getAuthorizedCollection(queryString); const expected = cold('a|', { - a: paginatedListRD + a: paginatedListRD, }); expect(result).toBeObservable(expected); }); @@ -153,7 +169,7 @@ describe('CollectionDataService', () => { it('should return a RemoteData> for the getAuthorizedCollectionByCommunity', () => { const result = service.getAuthorizedCollectionByCommunity(communityId, queryString); const expected = cold('a|', { - a: paginatedListRD + a: paginatedListRD, }); expect(result).toBeObservable(expected); }); @@ -200,19 +216,17 @@ describe('CollectionDataService', () => { } rdbService = jasmine.createSpyObj('rdbService', { buildList: hot('a|', { - a: paginatedListRD + a: paginatedListRD, }), buildFromRequestUUID: buildResponse$, - buildSingle: buildResponse$ - }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + buildSingle: buildResponse$, }); + 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/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 405b35c1f9..b2d5476d21 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -2,41 +2,51 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { INotification } from '../../shared/notifications/models/notification.model'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; 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 { ObjectCacheService } from '../cache/object-cache.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Collection } from '../shared/collection.model'; -import { COLLECTION } from '../shared/collection.resource-type'; +import { Community } from '../shared/community.model'; import { ContentSource } from '../shared/content-source.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { + getAllCompletedRemoteData, + getFirstCompletedRemoteData, +} from '../shared/operators'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { ContentSourceRequest, - UpdateContentSourceRequest + UpdateContentSourceRequest, } from './request.models'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; import { RestRequest } from './rest-request.model'; -import { FindListOptions } from './find-list-options.model'; -import { Community } from '../shared/community.model'; -import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(COLLECTION) +@Injectable({ providedIn: 'root' }) export class CollectionDataService extends ComColDataService { protected errorTitle = 'collection.source.update.notifications.error.title'; protected contentSourceError = 'collection.source.update.notifications.error.content'; @@ -73,11 +83,12 @@ export class CollectionDataService extends ComColDataService { getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { const searchHref = 'findSubmitAuthorized'; options = Object.assign({}, options, { - searchParams: [new RequestParam('query', query)] + searchParams: [new RequestParam('query', query)], }); return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** @@ -102,12 +113,13 @@ export class CollectionDataService extends ComColDataService { options = Object.assign({}, options, { searchParams: [ new RequestParam('query', query), - new RequestParam('entityType', entityType) - ] + new RequestParam('entityType', entityType), + ], }); return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** @@ -121,17 +133,18 @@ export class CollectionDataService extends ComColDataService { * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true): Observable>> { const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [ new RequestParam('uuid', communityId), - new RequestParam('query', query) - ] + new RequestParam('query', query), + ], }); return this.searchBy(searchHref, options, reRequestOnStale).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** * Get all collections the user is authorized to submit to, by community and has the metadata @@ -154,15 +167,16 @@ export class CollectionDataService extends ComColDataService { const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType'; const searchParams = [ new RequestParam('uuid', communityId), - new RequestParam('entityType', entityType) + new RequestParam('entityType', entityType), ]; options = Object.assign({}, options, { - searchParams: searchParams + searchParams: searchParams, }); return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** @@ -177,9 +191,8 @@ export class CollectionDataService extends ComColDataService { options.elementsPerPage = 1; return this.searchBy(searchHref, options).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending), - take(1), - map((collections: RemoteData>) => collections.payload.totalElements > 0) + getFirstCompletedRemoteData(), + map((collections: RemoteData>) => collections?.payload?.totalElements > 0), ); } @@ -189,7 +202,7 @@ export class CollectionDataService extends ComColDataService { */ getHarvesterEndpoint(collectionId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)), ); } @@ -200,7 +213,7 @@ export class CollectionDataService extends ComColDataService { getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable> { const href$ = this.getHarvesterEndpoint(collectionId).pipe( isNotEmptyOperator(), - take(1) + take(1), ); href$.subscribe((href: string) => { @@ -227,7 +240,7 @@ export class CollectionDataService extends ComColDataService { headers = headers.append('Content-Type', 'application/json'); options.headers = headers; return new UpdateContentSourceRequest(requestId, href, JSON.stringify(serializedContentSource), options); - }) + }), ); // Execute the post/put request @@ -255,7 +268,7 @@ export class CollectionDataService extends ComColDataService { return (response as RemoteData).payload; } return response as INotification; - }) + }), ); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 0f9f0fa740..e1fe48c076 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -1,28 +1,37 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { cold } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core-state.model'; +import { Bitstream } from '../shared/bitstream.model'; import { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RequestService } from './request.service'; -import { createFailedRemoteDataObject, createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { BitstreamDataService } from './bitstream-data.service'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; -import { Bitstream } from '../shared/bitstream.model'; -import { testCreateDataImplementation } from './base/create-data.spec'; -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 { RequestService } from './request.service'; const LINK_NAME = 'test'; @@ -45,7 +54,7 @@ class TestService extends ComColDataService { protected http: HttpClient, protected bitstreamDataService: BitstreamDataService, protected comparator: DSOChangeAnalyzer, - protected linkPath: string + protected linkPath: string, ) { super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } @@ -79,30 +88,30 @@ describe('ComColDataService', () => { const comparator = {} as any; const options = Object.assign(new FindListOptions(), { - scopeID: scopeID + scopeID: scopeID, }); const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; const mockHalService = { - getEndpoint: (linkPath) => observableOf(communitiesEndpoint) + getEndpoint: (linkPath) => observableOf(communitiesEndpoint), }; function initRdbService(): RemoteDataBuildService { return jasmine.createSpyObj('rdbService', { - buildSingle : createFailedRemoteDataObject$('Error', 500) + buildSingle : createFailedRemoteDataObject$('Error', 500), }); } function initBitstreamDataService(): BitstreamDataService { return jasmine.createSpyObj('bitstreamDataService', { - deleteByHref: createSuccessfulRemoteDataObject$({}) + deleteByHref: createSuccessfulRemoteDataObject$({}), }); } function initMockCommunityDataService(): CommunityDataService { return jasmine.createSpyObj('cds', { getEndpoint: cold('--a-', { a: communitiesEndpoint }), - getIDHref: communityEndpoint + getIDHref: communityEndpoint, }); } @@ -112,11 +121,11 @@ describe('ComColDataService', () => { d: { _links: { [LINK_NAME]: { - href: scopedEndpoint - } - } - } - }) + href: scopedEndpoint, + }, + }, + }, + }), }); } @@ -132,7 +141,7 @@ describe('ComColDataService', () => { http, bitstreamDataService, comparator, - LINK_NAME + LINK_NAME, ); } @@ -200,12 +209,12 @@ describe('ComColDataService', () => { communityWithParentHref = { _links: { parentCommunity: { - href: 'topLevel/parentCommunity' - } - } + href: 'topLevel/parentCommunity', + }, + }, } as Community; communityWithoutParentHref = { - _links: {} + _links: {}, } as Community; }); @@ -238,9 +247,9 @@ describe('ComColDataService', () => { id: 'a20da287-e174-466a-9926-f66as300d399', metadata: [{ key: 'dc.title', - value: 'parent community' + value: 'parent community', }], - _links: {} + _links: {}, }); }); it('should refresh a specific cached community when the parent link can be resolved', () => { @@ -262,9 +271,9 @@ describe('ComColDataService', () => { dso = { _links: { logo: { - href: 'logo-href' - } - } + href: 'logo-href', + }, + }, }; }); @@ -291,8 +300,8 @@ describe('ComColDataService', () => { _links: { self: { href: 'logo-href', - } - } + }, + }, }); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index abc9046cd0..de0d1a3157 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,34 +1,63 @@ -import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { Operation } from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; +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 { ObjectCacheService } from '../cache/object-cache.service'; -import { Community } from '../shared/community.model'; -import { HALLink } from '../shared/hal-link.model'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { Bitstream } from '../shared/bitstream.model'; import { Collection } from '../shared/collection.model'; -import { BitstreamDataService } from './bitstream-data.service'; +import { Community } from '../shared/community.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { HALLink } from '../shared/hal-link.model'; import { NoContent } from '../shared/NoContent.model'; -import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { FindListOptions } from './find-list-options.model'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { RestRequestMethod } from './rest-request-method'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { RequestParam } from '../cache/models/request-param.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { BitstreamDataService } from './bitstream-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { Operation } from 'fast-json-patch'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RestRequestMethod } from './rest-request-method'; export abstract class ComColDataService extends IdentifiableDataService implements CreateData, FindAllData, SearchData, PatchData, DeleteData { private createData: CreateData; @@ -86,7 +115,7 @@ export abstract class ComColDataService extend }), filter((halLink: HALLink) => isNotEmpty(halLink)), map((halLink: HALLink) => halLink.href), - distinctUntilChanged() + distinctUntilChanged(), ); } } @@ -97,7 +126,7 @@ export abstract class ComColDataService extend public findByParent(parentUUID: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getFindByParentHref(parentUUID).pipe( - map((href: string) => this.buildHrefFromFindOptions(href, options)) + map((href: string) => this.buildHrefFromFindOptions(href, options)), ); return this.findListByHref(href$, options, true, true, ...linksToFollow); } @@ -110,7 +139,7 @@ export abstract class ComColDataService extend return this.halService.getEndpoint(this.linkPath).pipe( // We can't use HalLinkService to discover the logo link itself, as objects without a logo // don't have the link, and this method is also used in the createLogo method. - map((href: string) => new URLCombiner(href, id, 'logo').toString()) + map((href: string) => new URLCombiner(href, id, 'logo').toString()), ); } @@ -132,7 +161,7 @@ export abstract class ComColDataService extend } else { return this.bitstreamDataService.deleteByHref(logoRd.payload._links.self.href); } - }) + }), ); } else { return createFailedRemoteDataObject$(`The given object doesn't have a logo`, 400); @@ -148,7 +177,7 @@ export abstract class ComColDataService extend this.findByHref(parentCommunityUrl).pipe( getFirstCompletedRemoteData(), ), - this.halService.getEndpoint('communities/search/top').pipe(take(1)) + this.halService.getEndpoint('communities/search/top').pipe(take(1)), ]).subscribe(([rd, topHref]: [RemoteData, string]) => { if (rd.hasSucceeded && isNotEmpty(rd.payload) && isNotEmpty(rd.payload.id)) { this.requestService.setStaleByHrefSubstring(rd.payload.id); diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index efb6d50e84..79dedf0c84 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,26 +1,28 @@ import { Injectable } from '@angular/core'; - import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { isNotEmpty } 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 { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; -import { COMMUNITY } from '../shared/community.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { isNotEmpty } from '../../shared/empty.util'; -import { FindListOptions } from './find-list-options.model'; -import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(COMMUNITY) +@Injectable({ providedIn: 'root' }) export class CommunityDataService extends ComColDataService { protected topLinkPath = 'search/top'; @@ -44,14 +46,14 @@ export class CommunityDataService extends ComColDataService { findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.getEndpoint().pipe( map(href => `${href}/search/top`), - switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)) + switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)), ); } protected getFindByParentHref(parentUUID: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( switchMap((communityEndpointHref: string) => - this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)) + this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)), ); } @@ -59,7 +61,7 @@ export class CommunityDataService extends ComColDataService { return this.getEndpoint().pipe( map((endpoint: string) => this.getIDHref(endpoint, options.scopeID)), filter((href: string) => isNotEmpty(href)), - take(1) + take(1), ); } } diff --git a/src/app/core/data/configuration-data.service.spec.ts b/src/app/core/data/configuration-data.service.spec.ts index 7fe69c16e5..bccfe45da4 100644 --- a/src/app/core/data/configuration-data.service.spec.ts +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -1,12 +1,16 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigurationDataService } from './configuration-data.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { ConfigurationDataService } from './configuration-data.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; describe('ConfigurationDataService', () => { let scheduler: TestScheduler; @@ -18,7 +22,7 @@ describe('ConfigurationDataService', () => { const testObject = { uuid: 'test-property', name: 'test-property', - values: ['value-1', 'value-2'] + values: ['value-1', 'value-2'], } as ConfigurationProperty; const configLink = 'https://rest.api/rest/api/config/properties'; const requestURL = `https://rest.api/rest/api/config/properties/${testObject.name}`; @@ -28,18 +32,18 @@ describe('ConfigurationDataService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: configLink }) + getEndpoint: cold('a', { a: configLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { a: { - payload: testObject - } - }) + payload: testObject, + }, + }), }); objectCache = {} as ObjectCacheService; @@ -70,8 +74,8 @@ describe('ConfigurationDataService', () => { const result = service.findByPropertyName(testObject.name); const expected = cold('a', { a: { - payload: testObject - } + payload: testObject, + }, }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index 557e13f57b..bb1bd19ff1 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,17 +1,15 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { IdentifiableDataService } from './base/identifiable-data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(CONFIG_PROPERTY) +@Injectable({ providedIn: 'root' }) /** * Data Service responsible for retrieving Configuration properties */ diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts index 066ccf28c9..4c0fd789fb 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; + import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ContentSource } from '../shared/content-source.model'; import { MetadataConfig } from '../shared/metadata-config.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a ContentSource object */ diff --git a/src/app/core/data/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index 992a29e4b8..d6aeca7965 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; + import { RestResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class DebugResponseParsingService implements ResponseParsingService { parse(request: RestRequest, data: RawRestResponse): RestResponse { console.log('request', request, 'data', data); diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 70c45bbc2d..fa08af018a 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -1,16 +1,19 @@ import { Injectable } from '@angular/core'; -import { compare } from 'fast-json-patch'; -import { Operation } from 'fast-json-patch'; +import { + compare, + Operation, +} from 'fast-json-patch'; + import { getClassForType } from '../cache/builders/build-decorators'; +import { TypedObject } from '../cache/typed-object.model'; import { DSpaceNotNullSerializer } from '../dspace-rest/dspace-not-null.serializer'; import { ChangeAnalyzer } from './change-analyzer'; -import { TypedObject } from '../cache/typed-object.model'; /** * A class to determine what differs between two * CacheableObjects */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DefaultChangeAnalyzer implements ChangeAnalyzer { /** * Compare the metadata of two CacheableObject and return the differences as diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index a621895633..95e7b5d69f 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -1,15 +1,19 @@ -import { compare, Operation } from 'fast-json-patch'; -import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; +import { + compare, + Operation, +} from 'fast-json-patch'; +import cloneDeep from 'lodash/cloneDeep'; + import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataMap } from '../shared/metadata.models'; -import cloneDeep from 'lodash/cloneDeep'; +import { ChangeAnalyzer } from './change-analyzer'; /** * A class to determine what differs between two * DSpaceObjects */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DSOChangeAnalyzer implements ChangeAnalyzer { /** diff --git a/src/app/core/data/dso-redirect.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts index 9271bd5f7f..b6b72583ea 100644 --- a/src/app/core/data/dso-redirect.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -1,18 +1,25 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + +import { AppConfig } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DsoRedirectService } from './dso-redirect.service'; -import { GetRequest, IdentifierType } from './request.models'; -import { RequestService } from './request.service'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { Item } from '../shared/item.model'; import { EMBED_SEPARATOR } from './base/base-data.service'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { environment } from '../../../environments/environment.test'; -import { AppConfig } from '../../../config/app-config.interface'; +import { DsoRedirectService } from './dso-redirect.service'; +import { + GetRequest, + IdentifierType, +} from './request.models'; +import { RequestService } from './request.service'; describe('DsoRedirectService', () => { let scheduler: TestScheduler; @@ -35,26 +42,26 @@ describe('DsoRedirectService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: pidLink }) + getEndpoint: cold('a', { a: pidLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); remoteData = createSuccessfulRemoteDataObject(Object.assign(new Item(), { type: 'item', - uuid: '123456789' + uuid: '123456789', })); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { - a: remoteData - }) + a: remoteData, + }), }); redirectService = jasmine.createSpyObj('redirectService', { - redirect: {} + redirect: {}, }); service = new DsoRedirectService( @@ -63,7 +70,7 @@ describe('DsoRedirectService', () => { rdbService, objectCache, halService, - redirectService + redirectService, ); }); @@ -118,8 +125,8 @@ describe('DsoRedirectService', () => { 'dspace.entity.type': [ { language: 'en_US', - value: 'Publication' - } + value: 'Publication', + }, ], }; const redir = service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE); diff --git a/src/app/core/data/dso-redirect.service.ts b/src/app/core/data/dso-redirect.service.ts index 4585df5b4b..28628a6246 100644 --- a/src/app/core/data/dso-redirect.service.ts +++ b/src/app/core/data/dso-redirect.service.ts @@ -6,22 +6,29 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { Injectable, Inject } from '@angular/core'; +import { + Inject, + Injectable, +} from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; + +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; +import { getDSORoute } from '../../app-routing-paths'; import { hasValue } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; import { RemoteData } from './remote-data'; import { IdentifierType } from './request.models'; import { RequestService } from './request.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { getDSORoute } from '../../app-routing-paths'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; const ID_ENDPOINT = 'pid'; const UUID_ENDPOINT = 'dso'; @@ -43,7 +50,7 @@ class DsoByIdOrUUIDDataService extends IdentifiableDataService { // interpolate id/uuid as query parameter (endpoint: string, resourceID: string): string => { return endpoint.replace(/{\?id}/, `?id=${resourceID}`) - .replace(/{\?uuid}/, `?uuid=${resourceID}`); + .replace(/{\?uuid}/, `?uuid=${resourceID}`); }, ); } @@ -66,7 +73,7 @@ class DsoByIdOrUUIDDataService extends IdentifiableDataService { * A service to handle redirects from identifier paths to DSO path * e.g.: redirect from /handle/... to /items/... */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DsoRedirectService { private dataService: DsoByIdOrUUIDDataService; @@ -76,7 +83,7 @@ export class DsoRedirectService { protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - private hardRedirectService: HardRedirectService + private hardRedirectService: HardRedirectService, ) { this.dataService = new DsoByIdOrUUIDDataService(requestService, rdbService, objectCache, halService); } @@ -84,7 +91,7 @@ export class DsoRedirectService { /** * Redirect to a DSpaceObject's path using the given identifier type and ID. * This is used to redirect paths like "/handle/[prefix]/[suffix]" to the object's path (e.g. /items/[uuid]). - * See LookupGuard for more examples. + * See lookupGuard for more examples. * * @param id the identifier of the object to retrieve * @param identifierType the type of the given identifier (defaults to UUID) @@ -97,14 +104,14 @@ export class DsoRedirectService { if (response.hasSucceeded) { const dso = response.payload; if (hasValue(dso.uuid)) { - let newRoute = getDSORoute(dso); + const newRoute = getDSORoute(dso); if (hasValue(newRoute)) { // Use a "301 Moved Permanently" redirect for SEO purposes this.hardRedirectService.redirect(this.appConfig.ui.nameSpace.replace(/\/$/, '') + newRoute, 301); } } } - }) + }), ); } } diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 74117e79d3..5cabba29eb 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -1,20 +1,25 @@ import { Injectable } from '@angular/core'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { + DSOSuccessResponse, + RestResponse, +} from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; - -import { ResponseParsingService } from './parsing.service'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; /** * @deprecated use DspaceRestResponseParsingService for new code, this is only left to support a * few legacy use cases, and should get removed eventually */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = true; diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 0f167ea47e..1e4809ac4b 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -1,12 +1,16 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DSpaceObjectDataService } from './dspace-object-data.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { DSpaceObjectDataService } from './dspace-object-data.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -16,7 +20,7 @@ describe('DSpaceObjectDataService', () => { let rdbService: RemoteDataBuildService; let objectCache: ObjectCacheService; const testObject = { - uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746' + uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746', } as DSpaceObject; const dsoLink = 'https://rest.api/rest/api/dso/find{?uuid}'; const requestURL = `https://rest.api/rest/api/dso/find?uuid=${testObject.uuid}`; @@ -26,18 +30,18 @@ describe('DSpaceObjectDataService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: dsoLink }) + getEndpoint: cold('a', { a: dsoLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { a: { - payload: testObject - } - }) + payload: testObject, + }, + }), }); objectCache = {} as ObjectCacheService; @@ -68,8 +72,8 @@ describe('DSpaceObjectDataService', () => { const result = service.findById(testObject.uuid); const expected = cold('a', { a: { - payload: testObject - } + payload: testObject, + }, }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 2ad024133c..bdebbd0582 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,15 +1,13 @@ import { Injectable } from '@angular/core'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; +import { RequestService } from './request.service'; -@Injectable() -@dataService(DSPACE_OBJECT) +@Injectable({ providedIn: 'root' }) export class DSpaceObjectDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/data/dspace-rest-response-parsing.service.ts b/src/app/core/data/dspace-rest-response-parsing.service.ts index 500afc4aff..0177a9813a 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -1,23 +1,34 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { environment } from '../../../environments/environment'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; -import { ParsedResponse } from '../cache/response.models'; -import { RestRequestMethod } from './rest-request-method'; -import { getUrlWithoutEmbedParams, getEmbedSizeParams } from '../index/index.selectors'; -import { URLCombiner } from '../url-combiner/url-combiner'; + +import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { getClassForType } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ParsedResponse } from '../cache/response.models'; +import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { + getEmbedSizeParams, + getUrlWithoutEmbedParams, +} from '../index/index.selectors'; +import { Serializer } from '../serializer'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { PageInfo } from '../shared/page-info.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; /** @@ -144,8 +155,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService console.warn(`The response for '${request.href}' doesn't have a self link. This could mean there's an issue with the REST endpoint`); response.payload._links = Object.assign({}, response.payload._links, { self: { - href: urlWithoutEmbedParams - } + href: urlWithoutEmbedParams, + }, }); } else { @@ -155,8 +166,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService console.warn(`The response for '${urlWithoutEmbedParams}' has the self link '${response.payload._links.self.href}'. These don't match. This could mean there's an issue with the REST endpoint`); response.payload._links = Object.assign({}, response.payload._links, { self: { - href: urlWithoutEmbedParams - } + href: urlWithoutEmbedParams, + }, }); } } @@ -184,8 +195,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService protected processArray(data: any, request: RestRequest): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, request)]; - } + array = [...array, this.process(datum, request)]; + }, ); return array; } @@ -231,7 +242,7 @@ export class DspaceRestResponseParsingService implements ResponseParsingService let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index 728714876c..c7dd40b98b 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; -import { - DspaceRestResponseParsingService, - isCacheableObject -} from './dspace-rest-response-parsing.service'; +import { environment } from '../../../environments/environment'; import { hasValue } from '../../shared/empty.util'; import { getClassForType } from '../cache/builders/build-decorators'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { ParsedResponse } from '../cache/response.models'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { environment } from '../../../environments/environment'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { ParsedResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { + DspaceRestResponseParsingService, + isCacheableObject, +} from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; /** @@ -20,7 +20,7 @@ import { RestRequest } from './rest-request.model'; * * When all endpoints are properly typed, it can be removed. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class EndpointMapResponseParsingService extends DspaceRestResponseParsingService { /** @@ -56,7 +56,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing } catch (e) { console.warn(`Couldn't parse endpoint request at ${request.href}`); return new ParsedResponse(response.statusCode, undefined, { - _links: response.payload._links + _links: response.payload._links, }); } } @@ -101,7 +101,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/entity-type-data.service.ts b/src/app/core/data/entity-type-data.service.ts index 4020ff638d..d47fadce17 100644 --- a/src/app/core/data/entity-type-data.service.ts +++ b/src/app/core/data/entity-type-data.service.ts @@ -1,26 +1,41 @@ -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; -import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; -import { PaginatedList } from './paginated-list.model'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemType } from '../shared/item-relationships/item-type.model'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { RelationshipTypeDataService } from './relationship-type-data.service'; -import { FindListOptions } from './find-list-options.model'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { + getAllCompletedRemoteData, + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; import { BaseDataService } from './base/base-data.service'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RelationshipTypeDataService } from './relationship-type-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Service handling all ItemType requests */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class EntityTypeDataService extends BaseDataService implements FindAllData, SearchData { private findAllData: FindAllData; private searchData: SearchDataImpl; @@ -48,7 +63,7 @@ export class EntityTypeDataService extends BaseDataService implements */ getRelationshipTypesEndpoint(entityTypeId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)) + switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)), ); } @@ -74,8 +89,7 @@ export class EntityTypeDataService extends BaseDataService implements getAllAuthorizedRelationshipType(options: FindListOptions = {}): Observable>> { const searchHref = 'findAllByAuthorizedCollection'; - return this.searchBy(searchHref, options).pipe( - filter((type: RemoteData>) => !type.isResponsePending)); + return this.searchBy(searchHref, options).pipe(getAllCompletedRemoteData()); } /** @@ -84,7 +98,7 @@ export class EntityTypeDataService extends BaseDataService implements hasMoreThanOneAuthorized(): Observable { const findListOptions: FindListOptions = { elementsPerPage: 2, - currentPage: 1 + currentPage: 1, }; return this.getAllAuthorizedRelationshipType(findListOptions).pipe( map((result: RemoteData>) => { @@ -95,7 +109,7 @@ export class EntityTypeDataService extends BaseDataService implements output = false; } return output; - }) + }), ); } @@ -108,8 +122,7 @@ export class EntityTypeDataService extends BaseDataService implements getAllAuthorizedRelationshipTypeImport(options: FindListOptions = {}): Observable>> { const searchHref = 'findAllByAuthorizedExternalSource'; - return this.searchBy(searchHref, options).pipe( - filter((type: RemoteData>) => !type.isResponsePending)); + return this.searchBy(searchHref, options).pipe(getAllCompletedRemoteData()); } /** @@ -118,18 +131,11 @@ export class EntityTypeDataService extends BaseDataService implements hasMoreThanOneAuthorizedImport(): Observable { const findListOptions: FindListOptions = { elementsPerPage: 2, - currentPage: 1 + currentPage: 1, }; return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( - map((result: RemoteData>) => { - let output: boolean; - if (result.payload) { - output = ( result.payload.page.length > 1 ); - } else { - output = false; - } - return output; - }) + take(1), + map((result: RemoteData>) => result?.payload?.totalElements > 1), ); } diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index afd4927103..a60cef121a 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -1,16 +1,17 @@ -import { RequestService } from './request.service'; -import { EpersonRegistrationService } from './eperson-registration.service'; -import { RestResponse } from '../cache/response.models'; +import { HttpHeaders } from '@angular/common/http'; import { cold } from 'jasmine-marbles'; -import { PostRequest } from './request.models'; -import { Registration } from '../shared/registration.model'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { RequestEntry } from './request-entry.model'; -import { HttpHeaders } from '@angular/common/http'; + +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RestResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Registration } from '../shared/registration.model'; +import { EpersonRegistrationService } from './eperson-registration.service'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; describe('EpersonRegistrationService', () => { let testScheduler; @@ -44,7 +45,7 @@ describe('EpersonRegistrationService', () => { generateRequestId: 'request-id', send: {}, getByUUID: cold('a', - { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }) + { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }), }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: observableOf(rd), @@ -53,7 +54,7 @@ describe('EpersonRegistrationService', () => { service = new EpersonRegistrationService( requestService, rdbService, - halService + halService, ); }); @@ -111,9 +112,9 @@ describe('EpersonRegistrationService', () => { payload: Object.assign(new Registration(), { email: registrationWithUser.email, token: 'test-token', - user: registrationWithUser.user - }) - }) + user: registrationWithUser.user, + }), + }), })); }); @@ -128,10 +129,10 @@ describe('EpersonRegistrationService', () => { jasmine.objectContaining({ uuid: 'request-id', method: 'GET', href: 'rest-url/registrations/search/findByToken?token=test-token', - }), true + }), true, ); expectObservable(rdbService.buildSingle.calls.argsFor(0)[0]).toBe('(a|)', { - a: 'rest-url/registrations/search/findByToken?token=test-token' + a: 'rest-url/registrations/search/findByToken?token=test-token', }); }); }); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 499d05af38..90a3fab83a 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -1,20 +1,33 @@ +import { + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { GetRequest, PostRequest } from './request.models'; import { Observable } from 'rxjs'; -import { filter, find, map } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { Registration } from '../shared/registration.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { ResponseParsingService } from './parsing.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { RegistrationResponseParsingService } from './registration-response-parsing.service'; -import { RemoteData } from './remote-data'; +import { + filter, + find, + map, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { HttpHeaders } from '@angular/common/http'; -import { HttpParams } from '@angular/common/http'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { Registration } from '../shared/registration.model'; +import { ResponseParsingService } from './parsing.service'; +import { RegistrationResponseParsingService } from './registration-response-parsing.service'; +import { RemoteData } from './remote-data'; +import { + GetRequest, + PostRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -81,11 +94,11 @@ export class EpersonRegistrationService { map((href: string) => { const request = new PostRequest(requestId, href, registration, options); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ); } @@ -105,7 +118,7 @@ export class EpersonRegistrationService { Object.assign(request, { getResponseParser(): GenericConstructor { return RegistrationResponseParsingService; - } + }, }); this.requestService.send(request, true); }); @@ -117,7 +130,7 @@ export class EpersonRegistrationService { } else { return rd; } - }) + }), ); } diff --git a/src/app/core/data/external-source-data.service.spec.ts b/src/app/core/data/external-source-data.service.spec.ts index 723d7f9bed..5e643cc549 100644 --- a/src/app/core/data/external-source-data.service.spec.ts +++ b/src/app/core/data/external-source-data.service.spec.ts @@ -1,11 +1,12 @@ -import { ExternalSourceDataService } from './external-source-data.service'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { of as observableOf } from 'rxjs'; -import { GetRequest } from './request.models'; import { testSearchDataImplementation } from './base/search-data.spec'; -import { take } from 'rxjs/operators'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { GetRequest } from './request.models'; describe('ExternalSourceService', () => { let service: ExternalSourceDataService; @@ -22,10 +23,10 @@ describe('ExternalSourceService', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0001' - } - ] - } + value: 'https://orcid.org/0001-0001-0001-0001', + }, + ], + }, }), Object.assign(new ExternalSourceEntry(), { id: '0001-0001-0001-0002', @@ -34,20 +35,20 @@ describe('ExternalSourceService', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0002' - } - ] - } - }) + value: 'https://orcid.org/0001-0001-0001-0002', + }, + ], + }, + }), ]; function init() { requestService = jasmine.createSpyObj('requestService', { generateRequestId: 'request-uuid', - send: {} + send: {}, }); rdbService = jasmine.createSpyObj('rdbService', { - buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) + buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)), }); halService = jasmine.createSpyObj('halService', { getEndpoint: observableOf('external-sources-REST-endpoint'), @@ -96,12 +97,6 @@ describe('ExternalSourceService', () => { result.pipe(take(1)).subscribe(); expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), false); }); - - it('should return the entries', () => { - result.subscribe((resultRD) => { - expect(resultRD.payload.page).toBe(entries); - }); - }); }); }); }); diff --git a/src/app/core/data/external-source-data.service.ts b/src/app/core/data/external-source-data.service.ts index 02c5e4a53c..e7f123dd18 100644 --- a/src/app/core/data/external-source-data.service.ts +++ b/src/app/core/data/external-source-data.service.ts @@ -1,25 +1,37 @@ import { Injectable } from '@angular/core'; -import { ExternalSource } from '../shared/external-source.model'; -import { RequestService } from './request.service'; +import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmptyOperator, +} from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list.model'; +import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { FindListOptions } from './find-list-options.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { SearchData, SearchDataImpl } from './base/search-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service handling all external source requests */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ExternalSourceDataService extends IdentifiableDataService implements SearchData { private searchData: SearchData; @@ -50,7 +62,7 @@ export class ExternalSourceDataService extends IdentifiableDataService { return this.getBrowseEndpoint().pipe( map((href) => this.getIDHref(href, externalSourceId)), - switchMap((href) => this.halService.getEndpoint('entries', href)) + switchMap((href) => this.halService.getEndpoint('entries', href)), ); } @@ -78,7 +90,7 @@ export class ExternalSourceDataService extends IdentifiableDataService { return this.findListByHref(href$, undefined, !hasCachedErrorResponse, reRequestOnStale, ...linksToFollow as any); - }) + }), ) as any; } diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 3e4493c32b..4ae22c34d8 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; + +import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model'; import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; -import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class FacetConfigResponseParsingService extends DspaceRestResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse { @@ -16,19 +17,19 @@ export class FacetConfigResponseParsingService extends DspaceRestResponseParsing const filters = serializer.deserializeArray(config); const _links = { - self: data.payload._links.self + self: data.payload._links.self, }; // fill in the missing links section filters.forEach((filterConfig: SearchFilterConfig) => { _links[filterConfig.name] = { - href: filterConfig._links.self.href + href: filterConfig._links.self.href, }; }); const facetConfigResponse = Object.assign(new FacetConfigResponse(), { filters, - _links + _links, }); this.addToObjectCache(facetConfigResponse, request, data); diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 0911ed5073..5cd24770d8 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; + import { FacetValue } from '../../shared/search/models/facet-value.model'; -import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { FacetValues } from '../../shared/search/models/facet-values.model'; +import { ParsedResponse } from '../cache/response.models'; +import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class FacetValueResponseParsingService extends DspaceRestResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse { const payload = data.payload; diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index ae44d590a4..4048efe4ff 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -1,18 +1,26 @@ -import { AuthorizationDataService } from './authorization-data.service'; -import { SiteDataService } from '../site-data.service'; -import { Site } from '../../shared/site.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { FeatureID } from './feature-id'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; + import { hasValue } from '../../../shared/empty.util'; -import { RequestParam } from '../../cache/models/request-param.model'; -import { Authorization } from '../../shared/authorization.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { Feature } from '../../shared/feature.model'; -import { FindListOptions } from '../find-list-options.model'; -import { testSearchDataImplementation } from '../base/search-data.spec'; import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { Authorization } from '../../shared/authorization.model'; +import { Feature } from '../../shared/feature.model'; +import { Site } from '../../shared/site.model'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { FindListOptions } from '../find-list-options.model'; +import { SiteDataService } from '../site-data.service'; +import { AuthorizationDataService } from './authorization-data.service'; +import { FeatureID } from './feature-id'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; @@ -23,19 +31,19 @@ describe('AuthorizationDataService', () => { let ePerson: EPerson; const requestService = jasmine.createSpyObj('requestService', { - setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring') + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring'), }); function init() { site = Object.assign(new Site(), { id: 'test-site', _links: { - self: { href: 'test-site-href' } - } + self: { href: 'test-site-href' }, + }, }); ePerson = Object.assign(new EPerson(), { id: 'test-eperson', - uuid: 'test-eperson' + uuid: 'test-eperson', }); siteService = jasmine.createSpyObj('siteService', { find: observableOf(site), @@ -157,26 +165,26 @@ describe('AuthorizationDataService', () => { const validPayload = [ Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'invalid-feature' - })) + id: 'invalid-feature', + })), }), Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: featureID - })) - }) + id: featureID, + })), + }), ]; const invalidPayload = [ Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'invalid-feature' - })) + id: 'invalid-feature', + })), }), Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'another-invalid-feature' - })) - }) + id: 'another-invalid-feature', + })), + }), ]; const emptyPayload = []; diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index 9573042272..93d8f6b3ec 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -1,32 +1,47 @@ -import { Observable, of as observableOf } from 'rxjs'; import { Injectable } from '@angular/core'; -import { AUTHORIZATION } from '../../shared/authorization.resource-type'; -import { Authorization } from '../../shared/authorization.model'; -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + catchError, + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { SiteDataService } from '../site-data.service'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Authorization } from '../../shared/authorization.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { BaseDataService } from '../base/base-data.service'; +import { + SearchData, + SearchDataImpl, +} from '../base/search-data'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { SiteDataService } from '../site-data.service'; import { AuthorizationSearchParams } from './authorization-search-params'; import { oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; -import { FindListOptions } from '../find-list-options.model'; -import { BaseDataService } from '../base/base-data.service'; -import { SearchData, SearchDataImpl } from '../base/search-data'; -import { dataService } from '../base/data-service.decorator'; /** * A service to retrieve {@link Authorization}s from the REST API */ -@Injectable() -@dataService(AUTHORIZATION) +@Injectable({ providedIn: 'root' }) export class AuthorizationDataService extends BaseDataService implements SearchData { protected linkPath = 'authorizations'; protected searchByObjectPath = 'object'; @@ -75,7 +90,7 @@ export class AuthorizationDataService extends BaseDataService imp } }), catchError(() => observableOf([])), - oneAuthorizationMatchesFeature(featureId) + oneAuthorizationMatchesFeature(featureId), ); } @@ -100,7 +115,7 @@ export class AuthorizationDataService extends BaseDataService imp switchMap((url) => { if (hasNoValue(url)) { return this.siteService.find().pipe( - map((site) => site.self) + map((site) => site.self), ); } else { return observableOf(url); @@ -112,7 +127,7 @@ export class AuthorizationDataService extends BaseDataService imp map((url: string) => new AuthorizationSearchParams(url, ePersonUuid, featureId)), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - }) + }), ); this.addDependency(out$, objectUrl$); diff --git a/src/app/core/data/feature-authorization/authorization-utils.ts b/src/app/core/data/feature-authorization/authorization-utils.ts index a4e5e4d997..cd4bc452a5 100644 --- a/src/app/core/data/feature-authorization/authorization-utils.ts +++ b/src/app/core/data/feature-authorization/authorization-utils.ts @@ -1,13 +1,25 @@ -import { map, switchMap } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { AuthorizationSearchParams } from './authorization-search-params'; -import { SiteDataService } from '../site-data.service'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { AuthService } from '../../auth/auth.service'; import { Authorization } from '../../shared/authorization.model'; import { Feature } from '../../shared/feature.model'; -import { FeatureID } from './feature-id'; import { getFirstSucceededRemoteDataPayload } from '../../shared/operators'; +import { SiteDataService } from '../site-data.service'; +import { AuthorizationSearchParams } from './authorization-search-params'; +import { FeatureID } from './feature-id'; /** * Operator accepting {@link AuthorizationSearchParams} and adding the current {@link Site}'s selflink to the parameter's @@ -20,12 +32,12 @@ export const addSiteObjectUrlIfEmpty = (siteService: SiteDataService) => switchMap((params: AuthorizationSearchParams) => { if (hasNoValue(params.objectUrl)) { return siteService.find().pipe( - map((site) => Object.assign({}, params, { objectUrl: site.self })) + map((site) => Object.assign({}, params, { objectUrl: site.self })), ); } else { return observableOf(params); } - }) + }), ); /** @@ -42,17 +54,17 @@ export const addAuthenticatedUserUuidIfEmpty = (authService: AuthService) => switchMap((authenticated) => { if (authenticated) { return authService.getAuthenticatedUserFromStore().pipe( - map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid })) + map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid })), ); } else { return observableOf(params); } - }) + }), ); } else { return observableOf(params); } - }) + }), ); /** @@ -72,12 +84,12 @@ export const oneAuthorizationMatchesFeature = (featureID: FeatureID) => ...authorizations .filter((authorization: Authorization) => hasValue(authorization.feature)) .map((authorization: Authorization) => authorization.feature.pipe( - getFirstSucceededRemoteDataPayload() - )) + getFirstSucceededRemoteDataPayload(), + )), ]); } else { return observableOf([]); } }), - map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID).length > 0) + map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID.valueOf()).length > 0), ); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts index b41a322cb6..5af9f18b33 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user * isn't a Collection administrator */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CollectionAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts index 2ab77a00cc..2092fce110 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user * isn't a Community administrator */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CommunityAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts index 6c1f330c69..2cd9fefa93 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts @@ -1,23 +1,41 @@ -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + ResolveFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard'; -import { FeatureID } from '../feature-id'; import { AuthService } from '../../../auth/auth.service'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { Item } from '../../../shared/item.model'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; +import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard'; + +const object = { + self: 'test-selflink', +} as DSpaceObject; + +const testResolver: ResolveFn> = () => createSuccessfulRemoteDataObject$(object); /** * Test implementation of abstract class DsoPageSingleFeatureGuard */ class DsoPageSingleFeatureGuardImpl extends DsoPageSingleFeatureGuard { - constructor(protected resolver: Resolve>, - protected authorizationService: AuthorizationDataService, + + protected resolver: ResolveFn> = testResolver; + + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService, protected featureID: FeatureID) { - super(resolver, authorizationService, router, authService); + super(authorizationService, router, authService); } getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { @@ -30,39 +48,30 @@ describe('DsoPageSingleFeatureGuard', () => { let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; - let resolver: Resolve>; - let object: DSpaceObject; let route; let parentRoute; function init() { - object = { - self: 'test-selflink' - } as DSpaceObject; - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} - }); - resolver = jasmine.createSpyObj('resolver', { - resolve: createSuccessfulRemoteDataObject$(object) + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); parentRoute = { params: { - id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0' - } + id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0', + }, }; route = { params: { }, - parent: parentRoute + parent: parentRoute, }; - guard = new DsoPageSingleFeatureGuardImpl(resolver, authorizationService, router, authService, undefined); + guard = new DsoPageSingleFeatureGuardImpl(authorizationService, router, authService, undefined); } beforeEach(() => { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts index 3fc90f9069..1f75df846b 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts @@ -1,9 +1,13 @@ -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { FeatureID } from '../feature-id'; -import { map } from 'rxjs/operators'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { FeatureID } from '../feature-id'; +import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; /** * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts index 071b1b0731..4e741b5b71 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts @@ -1,23 +1,41 @@ -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + ResolveFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { FeatureID } from '../feature-id'; import { AuthService } from '../../../auth/auth.service'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { Item } from '../../../shared/item.model'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; +const object = { + self: 'test-selflink', +} as DSpaceObject; + +const testResolver: ResolveFn> = () => createSuccessfulRemoteDataObject$(object); + /** * Test implementation of abstract class DsoPageSomeFeatureGuard */ class DsoPageSomeFeatureGuardImpl extends DsoPageSomeFeatureGuard { - constructor(protected resolver: Resolve>, - protected authorizationService: AuthorizationDataService, + + protected resolver: ResolveFn> = testResolver; + + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService, protected featureIDs: FeatureID[]) { - super(resolver, authorizationService, router, authService); + super(authorizationService, router, authService); } getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { @@ -30,39 +48,31 @@ describe('DsoPageSomeFeatureGuard', () => { let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; - let resolver: Resolve>; - let object: DSpaceObject; + let route; let parentRoute; function init() { - object = { - self: 'test-selflink' - } as DSpaceObject; - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} - }); - resolver = jasmine.createSpyObj('resolver', { - resolve: createSuccessfulRemoteDataObject$(object) + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); parentRoute = { params: { - id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0' - } + id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0', + }, }; route = { params: { }, - parent: parentRoute + parent: parentRoute, }; - guard = new DsoPageSomeFeatureGuardImpl(resolver, authorizationService, router, authService, []); + guard = new DsoPageSomeFeatureGuardImpl(authorizationService, router, authService, []); } beforeEach(() => { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts index 8683709345..c887b8ae2a 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts @@ -1,12 +1,21 @@ -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + ResolveFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, +} from '../../../../shared/empty.util'; +import { AuthService } from '../../../auth/auth.service'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; import { RemoteData } from '../../remote-data'; import { AuthorizationDataService } from '../authorization-data.service'; -import { Observable } from 'rxjs'; -import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; -import { map } from 'rxjs/operators'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { AuthService } from '../../../auth/auth.service'; -import { hasNoValue, hasValue } from '../../../../shared/empty.util'; import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; /** @@ -14,8 +23,10 @@ import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guar * This guard utilizes a resolver to retrieve the relevant object to check authorizations for */ export abstract class DsoPageSomeFeatureGuard extends SomeFeatureAuthorizationGuard { - constructor(protected resolver: Resolve>, - protected authorizationService: AuthorizationDataService, + + protected abstract resolver: ResolveFn>; + + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { super(authorizationService, router, authService); @@ -26,14 +37,14 @@ export abstract class DsoPageSomeFeatureGuard extends So */ getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { const routeWithObjectID = this.getRouteWithDSOId(route); - return (this.resolver.resolve(routeWithObjectID, state) as Observable>).pipe( + return (this.resolver(routeWithObjectID, state) as Observable>).pipe( getAllSucceededRemoteDataPayload(), - map((dso) => dso.self) + map((dso) => dso.self), ); } /** - * Method to resolve resolve (parent) route that contains the UUID of the DSO + * Method to resolve (parent) route that contains the UUID of the DSO * @param route The current route */ protected getRouteWithDSOId(route: ActivatedRouteSnapshot): ActivatedRouteSnapshot { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts index 5afd572326..5f32e26851 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group * management rights */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class GroupAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts index 635aa3530b..e789f8c473 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts @@ -1,9 +1,17 @@ -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../auth/auth.service'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { Observable, of as observableOf } from 'rxjs'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Test implementation of abstract class SingleFeatureAuthorizationGuard @@ -48,13 +56,13 @@ describe('SingleFeatureAuthorizationGuard', () => { ePersonUuid = 'fake-eperson-uuid'; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); guard = new SingleFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts index cb71d2f418..cd9f615aa7 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts @@ -1,7 +1,11 @@ -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { FeatureID } from '../feature-id'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { map} from 'rxjs/operators'; +import { map } from 'rxjs/operators'; + +import { FeatureID } from '../feature-id'; import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; /** diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts index cc6f50c161..e4f0705def 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts @@ -1,18 +1,24 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { FeatureID } from '../feature-id'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator * rights to the {@link Site} */ -@Injectable({ - providedIn: 'root' -}) +@Injectable({ providedIn: 'root' }) export class SiteAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { super(authorizationService, router, authService); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts index bdbb8250e2..a1a78dc67d 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts @@ -1,18 +1,24 @@ -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; import { Injectable } from '@angular/core'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; -import { FeatureID } from '../feature-id'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration * rights to the {@link Site} */ -@Injectable({ - providedIn: 'root' -}) +@Injectable({ providedIn: 'root' }) export class SiteRegisterGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { super(authorizationService, router, authService); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts index 90153d2d14..53d77cadad 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts @@ -1,8 +1,16 @@ +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../auth/auth.service'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { Observable, of as observableOf } from 'rxjs'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; /** @@ -52,13 +60,13 @@ describe('SomeFeatureAuthorizationGuard', () => { authorizationService = Object.assign({ isAuthorized(featureId?: FeatureID): Observable { return observableOf(authorizedFeatureIds.indexOf(featureId) > -1); - } + }, }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); guard = new SomeFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureIds, objectUrl, ePersonUuid); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts index b909640ea6..229321452f 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts @@ -1,17 +1,27 @@ -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { FeatureID } from '../feature-id'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; + import { AuthService } from '../../../auth/auth.service'; import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/authorized.operators'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user * doesn't have authorized rights on any of the specified features and/or object. * Override the desired getters in the parent class for checking specific authorization on a list of features and/or object. */ -export abstract class SomeFeatureAuthorizationGuard implements CanActivate { +export abstract class SomeFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { @@ -24,9 +34,9 @@ export abstract class SomeFeatureAuthorizationGuard implements CanActivate { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( switchMap(([featureIDs, objectUrl, ePersonUuid]) => - observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))) + observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))), ), - returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url) + returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url), ); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts index 680495686e..b301d550a1 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts @@ -1,27 +1,35 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group * management rights */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class StatisticsAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { + super(authorizationService, router, authService); + } - /** + /** * Check group management rights */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.CanViewUsageStatistics); - } + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.CanViewUsageStatistics); + } } diff --git a/src/app/core/data/feature-authorization/feature-data.service.ts b/src/app/core/data/feature-authorization/feature-data.service.ts index eda8791153..191d8b002c 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -1,18 +1,16 @@ import { Injectable } from '@angular/core'; -import { FEATURE } from '../../shared/feature.resource-type'; -import { Feature } from '../../shared/feature.model'; -import { RequestService } from '../request.service'; + import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Feature } from '../../shared/feature.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { BaseDataService } from '../base/base-data.service'; -import { dataService } from '../base/data-service.decorator'; +import { RequestService } from '../request.service'; /** * A service to retrieve {@link Feature}s from the REST API */ -@Injectable() -@dataService(FEATURE) +@Injectable({ providedIn: 'root' }) export class FeatureDataService extends BaseDataService { protected linkPath = 'features'; diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index b5f0c60dc6..3d5803b018 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -34,6 +34,7 @@ export enum FeatureID { CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', CanSubscribe = 'canSubscribeDso', - EPersonForgotPassword = 'epersonForgotPassword', + CoarNotifyEnabled = 'coarNotifyEnabled', CanSeeQA = 'canSeeQA', + EPersonForgotPassword = 'epersonForgotPassword' } diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts index ac0e96a2e6..6966e6a631 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts @@ -1,10 +1,10 @@ -import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { GetRequest } from './request.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; describe('FilteredDiscoveryPageResponseParsingService', () => { let service: FilteredDiscoveryPageResponseParsingService; @@ -17,15 +17,15 @@ describe('FilteredDiscoveryPageResponseParsingService', () => { const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), { getResponseParser(): GenericConstructor { return FilteredDiscoveryPageResponseParsingService; - } + }, }); const mockResponse = { payload: { - 'discovery-query': 'query' + 'discovery-query': 'query', }, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => { diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts index da7a21c488..e07f46e916 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -1,16 +1,20 @@ import { Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; + +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + FilteredDiscoveryQueryResponse, + RestResponse, +} from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a discovery query (string) * wrapped in a FilteredDiscoveryQueryResponse */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class FilteredDiscoveryPageResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { objectFactory = {}; toCache = false; diff --git a/src/app/core/data/find-list-options.model.ts b/src/app/core/data/find-list-options.model.ts index dc567d4b53..78fe26fcab 100644 --- a/src/app/core/data/find-list-options.model.ts +++ b/src/app/core/data/find-list-options.model.ts @@ -1,15 +1,15 @@ -import { SortOptions } from '../cache/models/sort-options.model'; import { RequestParam } from '../cache/models/request-param.model'; +import { SortOptions } from '../cache/models/sort-options.model'; /** * The options for a find list request */ export class FindListOptions { - scopeID?: string; - elementsPerPage?: number; - currentPage?: number; - sort?: SortOptions; - searchParams?: RequestParam[]; - startsWith?: string; - fetchThumbnail?: boolean; + scopeID?: string; + elementsPerPage?: number; + currentPage?: number; + sort?: SortOptions; + searchParams?: RequestParam[]; + startsWith?: string; + fetchThumbnail?: boolean; } diff --git a/src/app/core/data/href-only-data.service.spec.ts b/src/app/core/data/href-only-data.service.spec.ts index bf7d2890ea..cba85d5de6 100644 --- a/src/app/core/data/href-only-data.service.spec.ts +++ b/src/app/core/data/href-only-data.service.spec.ts @@ -1,8 +1,11 @@ -import { HrefOnlyDataService } from './href-only-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { FindListOptions } from './find-list-options.model'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { BaseDataService } from './base/base-data.service'; +import { FindListOptions } from './find-list-options.model'; +import { HrefOnlyDataService } from './href-only-data.service'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; @@ -23,60 +26,60 @@ describe(`HrefOnlyDataService`, () => { expect((service as any).dataService).toBeInstanceOf(BaseDataService); }); - describe(`findByHref`, () => { - beforeEach(() => { - spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); - }); + describe(`findByHref`, () => { + beforeEach(() => { + spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + }); - it(`should forward to findByHref on the internal DataService`, () => { - service.findByHref(href, false, false, ...followLinks); - expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); - }); + it(`should forward to findByHref on the internal DataService`, () => { + service.findByHref(href, false, false, ...followLinks); + expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); + }); - describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), true, jasmine.anything()); - }); - }); - - describe(`when reRequestOnStale is omitted`, () => { - it(`should call findByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true); - }); + describe(`when useCachedVersionIfAvailable is omitted`, () => { + it(`should call findByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), true, jasmine.anything()); }); }); - describe(`findListByHref`, () => { - beforeEach(() => { - spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); - }); - - it(`should delegate to findListByHref on the internal DataService`, () => { - service.findListByHref(href, findListOptions, false, false, ...followLinks); - expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); - }); - - describe(`when findListOptions is omitted`, () => { - it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); - }); - }); - - describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); - }); - }); - - describe(`when reRequestOnStale is omitted`, () => { - it(`should call findListByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), jasmine.anything(), true); - }); + describe(`when reRequestOnStale is omitted`, () => { + it(`should call findByHref on the internal DataService with reRequestOnStale = true`, () => { + service.findByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true); }); }); + }); + + describe(`findListByHref`, () => { + beforeEach(() => { + spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + }); + + it(`should delegate to findListByHref on the internal DataService`, () => { + service.findListByHref(href, findListOptions, false, false, ...followLinks); + expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); + }); + + describe(`when findListOptions is omitted`, () => { + it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); + }); + }); + + describe(`when useCachedVersionIfAvailable is omitted`, () => { + it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); + }); + }); + + describe(`when reRequestOnStale is omitted`, () => { + it(`should call findListByHref on the internal DataService with reRequestOnStale = true`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), jasmine.anything(), true); + }); + }); + }); }); diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index 8393d71460..dd40be8f7d 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,20 +1,17 @@ -import { RequestService } from './request.service'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../cache/cacheable-object.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Injectable } from '@angular/core'; -import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RemoteData } from './remote-data'; -import { Observable } from 'rxjs'; -import { PaginatedList } from './paginated-list.model'; -import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; -import { LICENSE } from '../shared/license.resource-type'; -import { CacheableObject } from '../cache/cacheable-object.model'; -import { FindListOptions } from './find-list-options.model'; import { BaseDataService } from './base/base-data.service'; import { HALDataService } from './base/hal-data-service.interface'; -import { dataService } from './base/data-service.decorator'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A UpdateDataServiceImpl with only findByHref methods. Its purpose is to be used for resources that don't @@ -32,12 +29,7 @@ import { dataService } from './base/data-service.decorator'; * ``` * This means we cannot extend from {@link BaseDataService} directly because the method signatures would not match. */ -@Injectable({ - providedIn: 'root', -}) -@dataService(VOCABULARY_ENTRY) -@dataService(ITEM_TYPE) -@dataService(LICENSE) +@Injectable({ providedIn: 'root' }) export class HrefOnlyDataService implements HALDataService { /** * Works with a {@link BaseDataService} internally, but only exposes two of its methods diff --git a/src/app/core/data/identifier-data.service.ts b/src/app/core/data/identifier-data.service.ts index 03422dadfb..502b1fe710 100644 --- a/src/app/core/data/identifier-data.service.ts +++ b/src/app/core/data/identifier-data.service.ts @@ -1,27 +1,33 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { + HttpClient, + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from './base/data-service.decorator'; +import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { BaseDataService } from './base/base-data.service'; -import { RequestService } from './request.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { CoreState } from '../core-state.model'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { Item } from '../shared/item.model'; -import { IDENTIFIERS } from '../../shared/object-list/identifier-data/identifier-data.resource-type'; -import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { map, switchMap } from 'rxjs/operators'; -import {ConfigurationProperty} from '../shared/configuration-property.model'; -import {ConfigurationDataService} from './configuration-data.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { PostRequest } from './request.models'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; +import { BaseDataService } from './base/base-data.service'; +import { ConfigurationDataService } from './configuration-data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { RemoteData } from './remote-data'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; import { RestRequest } from './rest-request.model'; /** @@ -29,8 +35,7 @@ import { RestRequest } from './rest-request.model'; * from the /identifiers endpoint, as well as the backend configuration that controls whether a 'Register DOI' * button appears for admins in the item status page */ -@Injectable() -@dataService(IDENTIFIERS) +@Injectable({ providedIn: 'root' }) export class IdentifierDataService extends BaseDataService { constructor( @@ -61,7 +66,7 @@ export class IdentifierDataService extends BaseDataService { public getIdentifierRegistrationConfiguration(): Observable { return this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe( getFirstCompletedRemoteData(), - map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), ); } @@ -79,7 +84,7 @@ export class IdentifierDataService extends BaseDataService { return new PostRequest(requestId, endpointURL, item._links.self.href, options); }), sendRequest(this.requestService), - switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>), ); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 2c20ed0fb6..dd60d94070 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,25 +1,32 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { BrowseService } from '../browse/browse.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; -import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; -import { RequestService } from './request.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; -import { FindListOptions } from './find-list-options.model'; -import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { FindListOptions } from './find-list-options.model'; +import { ItemDataService } from './item-data.service'; +import { + DeleteRequest, + PostRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -46,7 +53,7 @@ describe('ItemDataService', () => { const objectCache = {} as ObjectCacheService; const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { - findByHref: {} + findByHref: {}, }); const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; @@ -54,8 +61,8 @@ describe('ItemDataService', () => { scopeID: scopeID, sort: { field: '', - direction: undefined - } + direction: undefined, + }, }); const browsesEndpoint = 'https://rest.api/discover/browses'; @@ -73,7 +80,7 @@ describe('ItemDataService', () => { cold('--a-', { a: itemBrowseEndpoint }) : cold('--#-', undefined, browseError); return jasmine.createSpyObj('bs', { - getBrowseURLFor: obs + getBrowseURLFor: obs, }); } @@ -158,7 +165,7 @@ describe('ItemDataService', () => { const externalSourceEntry = Object.assign(new ExternalSourceEntry(), { display: 'John, Doe', value: 'John, Doe', - _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } } + _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } }, }); beforeEach(() => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c3fa84dd6c..e1f789b5da 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -8,44 +8,71 @@ /* eslint-disable max-classes-per-file */ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { + distinctUntilChanged, + filter, + find, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Bundle } from '../shared/bundle.model'; import { Collection } from '../shared/collection.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { ITEM } from '../shared/item.resource-type'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { DeleteRequest, GetRequest, PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Bundle } from '../shared/bundle.model'; import { MetadataMap } from '../shared/metadata.models'; -import { BundleDataService } from './bundle-data.service'; -import { Operation } from 'fast-json-patch'; import { NoContent } from '../shared/NoContent.model'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { ResponseParsingService } from './parsing.service'; -import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; import { sendRequest } from '../shared/request.operators'; -import { RestRequest } from './rest-request.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { BundleDataService } from './bundle-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { FindListOptions } from './find-list-options.model'; -import { ConstructIdEndpoint, IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { PaginatedList } from './paginated-list.model'; +import { ResponseParsingService } from './parsing.service'; +import { RemoteData } from './remote-data'; +import { + DeleteRequest, + GetRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import { RestRequest } from './rest-request.model'; import { RestRequestMethod } from './rest-request-method'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { RequestParam } from '../cache/models/request-param.model'; -import { dataService } from './base/data-service.decorator'; +import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; /** * An abstract service for CRUD operations on Items @@ -140,7 +167,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService return new PostRequest(this.requestService.generateRequestId(), endpointURL, collectionHref, options); }), sendRequest(this.requestService), - switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)) + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), ); } @@ -152,7 +179,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService public setWithDrawn(item: Item, withdrawn: boolean): Observable> { const patchOperation = { - op: 'replace', path: '/withdrawn', value: withdrawn + op: 'replace', path: '/withdrawn', value: withdrawn, } as Operation; this.requestService.removeByHrefSubstring('/discover'); @@ -166,7 +193,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public setDiscoverable(item: Item, discoverable: boolean): Observable> { const patchOperation = { - op: 'replace', path: '/discoverable', value: discoverable + op: 'replace', path: '/discoverable', value: discoverable, } as Operation; this.requestService.removeByHrefSubstring('/discover'); @@ -180,7 +207,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBundlesEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)), ); } @@ -191,10 +218,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable>> { const hrefObs = this.getBundlesEndpoint(itemId).pipe( - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href), ); hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const request = new GetRequest(this.requestService.generateRequestId(), href); this.requestService.send(request); @@ -215,11 +242,11 @@ export abstract class BaseItemDataService extends IdentifiableDataService const bundleJson = { name: bundleName, - metadata: metadata ? metadata : {} + metadata: metadata ? metadata : {}, }; hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -238,7 +265,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getIdentifiersEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`)), ); } @@ -249,7 +276,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService public getMoveItemEndpoint(itemId: string, inheritPolicies: boolean): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`) + map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`), ); } @@ -275,10 +302,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService // TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code getResponseParser(): GenericConstructor { return StatusCodeOnlyResponseParsingService; - } + }, }); return request; - }) + }), ).subscribe((request) => { this.requestService.send(request); }); @@ -305,7 +332,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService map((href: string) => { const request = new PostRequest(requestId, href, externalSourceEntry._links.self.href, options); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); @@ -317,7 +344,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBitstreamsEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)), ); } @@ -403,8 +430,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService /** * A service for CRUD operations on Items */ -@Injectable() -@dataService(ITEM) +@Injectable({ providedIn: 'root' }) export class ItemDataService extends BaseItemDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index a5d1872510..68577ae6e2 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -1,12 +1,13 @@ -import { ItemRequestDataService } from './item-request-data.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { ItemRequest } from '../shared/item-request.model'; -import { PostRequest } from './request.models'; + import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { ItemRequestDataService } from './item-request-data.service'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; describe('ItemRequestDataService', () => { diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index ff6025f7ac..5c85ed1471 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -1,20 +1,32 @@ +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { RemoteData } from './remote-data'; -import { PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { ItemRequest } from '../shared/item-request.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HttpHeaders } from '@angular/common/http'; +import { + distinctUntilChanged, + filter, + find, + map, +} from 'rxjs/operators'; + import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; import { IdentifiableDataService } from './base/identifiable-data.service'; +import { RemoteData } from './remote-data'; +import { + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint @@ -60,11 +72,11 @@ export class ItemRequestDataService extends IdentifiableDataService map((href: string) => { const request = new PostRequest(requestId, href, itemRequest); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ); } diff --git a/src/app/core/data/item-template-data.service.spec.ts b/src/app/core/data/item-template-data.service.spec.ts index 16cf0dbd99..27db819861 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -1,22 +1,26 @@ -import { ItemTemplateDataService } from './item-template-data.service'; -import { RestResponse } from '../cache/response.models'; -import { RequestService } from './request.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { CollectionDataService } from './collection-data.service'; -import { RestRequestMethod } from './rest-request-method'; -import { Item } from '../shared/item.model'; -import { RestRequest } from './rest-request.model'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { CollectionDataService } from './collection-data.service'; +import { ItemTemplateDataService } from './item-template-data.service'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; +import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; import createSpyObj = jasmine.createSpyObj; describe('ItemTemplateDataService', () => { @@ -46,7 +50,7 @@ describe('ItemTemplateDataService', () => { }, commit(method?: RestRequestMethod) { // Do nothing - } + }, } as RequestService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; @@ -62,18 +66,18 @@ describe('ItemTemplateDataService', () => { const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: itemEndpoint }); - } + }, } as HALEndpointService; const notificationsService = {} as NotificationsService; const comparator = { diff(first, second) { return [{}]; - } + }, } as any; const collectionService = { getIDHrefObs(id): Observable { return observableOf(collectionEndpoint); - } + }, } as CollectionDataService; function initTestService() { diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index 634c966dba..f89a297fad 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,22 +1,23 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { BaseItemDataService } from './item-data.service'; -import { Item } from '../shared/item.model'; -import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RequestService } from './request.service'; +import { switchMap } from 'rxjs/operators'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BrowseService } from '../browse/browse.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 { BrowseService } from '../browse/browse.service'; -import { CollectionDataService } from './collection-data.service'; -import { switchMap } from 'rxjs/operators'; -import { BundleDataService } from './bundle-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; +import { Item } from '../shared/item.model'; import { CreateDataImpl } from './base/create-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { BundleDataService } from './bundle-data.service'; +import { CollectionDataService } from './collection-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { BaseItemDataService } from './item-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Data service for interacting with Item templates via their Collection @@ -63,7 +64,7 @@ class CollectionItemTemplateDataService extends IdentifiableDataService { /** * A service responsible for fetching/sending data from/to the REST API on a collection's itemtemplates endpoint */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ItemTemplateDataService extends BaseItemDataService { private byCollection: CollectionItemTemplateDataService; diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index 58598b9870..93b4ed6696 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -1,18 +1,22 @@ -import { LookupRelationService } from './lookup-relation.service'; -import { ExternalSourceDataService } from './external-source-data.service'; -import { SearchService } from '../shared/search/search.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { buildPaginatedList } from './paginated-list.model'; -import { PageInfo } from '../shared/page-info.model'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; -import { SearchResult } from '../../shared/search/models/search-result.model'; -import { Item } from '../shared/item.model'; -import { skip, take } from 'rxjs/operators'; -import { ExternalSource } from '../shared/external-source.model'; -import { RequestService } from './request.service'; import { of as observableOf } from 'rxjs'; +import { + skip, + take, +} from 'rxjs/operators'; + +import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { SearchResult } from '../../shared/search/models/search-result.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { ExternalSource } from '../shared/external-source.model'; +import { Item } from '../shared/item.model'; +import { PageInfo } from '../shared/page-info.model'; +import { SearchService } from '../shared/search/search.service'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { LookupRelationService } from './lookup-relation.service'; +import { buildPaginatedList } from './paginated-list.model'; +import { RequestService } from './request.service'; describe('LookupRelationService', () => { let service: LookupRelationService; @@ -24,20 +28,20 @@ describe('LookupRelationService', () => { const optionsWithQuery = new PaginatedSearchOptions({ query: 'test-query' }); const relationship = Object.assign(new RelationshipOptions(), { filter: 'test-filter', - configuration: 'test-configuration' + configuration: 'test-configuration', }); const localResults = [ Object.assign(new SearchResult(), { indexableObject: Object.assign(new Item(), { uuid: 'test-item-uuid', - handle: 'test-item-handle' - }) - }) + handle: 'test-item-handle', + }), + }), ]; const externalSource = Object.assign(new ExternalSource(), { id: 'orcidV2', name: 'orcidV2', - hierarchical: false + hierarchical: false, }); const searchServiceEndpoint = 'http://test-rest.com/server/api/core/search'; @@ -47,12 +51,12 @@ describe('LookupRelationService', () => { elementsPerPage: 1, totalElements: totalExternal, totalPages: totalExternal, - currentPage: 1 - }), [{}])) + currentPage: 1, + }), [{}])), }); searchService = jasmine.createSpyObj('searchService', { search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)), - getEndpoint: observableOf(searchServiceEndpoint) + getEndpoint: observableOf(searchServiceEndpoint), }); requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); service = new LookupRelationService(externalSourceService, searchService, requestService); @@ -77,7 +81,7 @@ describe('LookupRelationService', () => { it('should set the searchConfig to contain a fixedFilter and configuration', () => { expect(service.searchConfig).toEqual(Object.assign(new PaginatedSearchOptions({}), optionsWithQuery, - { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration } + { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration }, )); }); }); diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 7a6bc2358b..55b68afa65 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -1,25 +1,40 @@ -import { ExternalSourceDataService } from './external-source-data.service'; -import { SearchService } from '../shared/search/search.service'; -import { concat, distinctUntilChanged, map, multicast, startWith, take, takeWhile } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { + Observable, + ReplaySubject, +} from 'rxjs'; +import { + concat, + distinctUntilChanged, + map, + multicast, + startWith, + take, + takeWhile, +} from 'rxjs/operators'; + +import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Observable, ReplaySubject } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list.model'; import { SearchResult } from '../../shared/search/models/search-result.model'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; -import { Item } from '../shared/item.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { Injectable } from '@angular/core'; import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { Item } from '../shared/item.model'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { SearchService } from '../shared/search/search.service'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; /** * A service for retrieving local and external entries information during a relation lookup */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class LookupRelationService { /** * The search config last used for retrieving local results @@ -31,7 +46,7 @@ export class LookupRelationService { */ private singleResultOptions = Object.assign(new PaginationComponentOptions(), { id: 'single-result-options', - pageSize: 1 + pageSize: 1, }); constructor(protected externalSourceService: ExternalSourceDataService, @@ -47,7 +62,7 @@ export class LookupRelationService { */ getLocalResults(relationship: RelationshipOptions, searchOptions: PaginatedSearchOptions, setSearchConfig = true): Observable>>> { const newConfig = Object.assign(new PaginatedSearchOptions({}), searchOptions, - { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration } + { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration }, ); if (setSearchConfig) { this.searchConfig = newConfig; @@ -59,8 +74,8 @@ export class LookupRelationService { () => new ReplaySubject(1), (subject) => subject.pipe( takeWhile((rd: RemoteData>>) => rd.isLoading), - concat(subject.pipe(take(1))) - ) + concat(subject.pipe(take(1))), + ), ) as any , ) as Observable>>>; @@ -76,7 +91,7 @@ export class LookupRelationService { getAllSucceededRemoteData(), getRemoteDataPayload(), map((results: PaginatedList>) => results.totalElements), - startWith(0) + startWith(0), ); } @@ -91,7 +106,7 @@ export class LookupRelationService { getRemoteDataPayload(), map((results: PaginatedList) => results.totalElements), startWith(0), - distinctUntilChanged() + distinctUntilChanged(), ); } diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index 1ce078f5d5..8d65038060 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -1,20 +1,21 @@ -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { MetadataFieldDataService } from './metadata-field-data.service'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { RequestParam } from '../cache/models/request-param.model'; -import { FindListOptions } from './find-list-options.model'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { RestResponse } from '../cache/response.models'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPutDataImplementation } from './base/put-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { FindListOptions } from './find-list-options.model'; +import { MetadataFieldDataService } from './metadata-field-data.service'; +import { RequestService } from './request.service'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -31,8 +32,8 @@ describe('MetadataFieldDataService', () => { prefix: 'dc', namespace: 'namespace', _links: { - self: { href: 'selflink' } - } + self: { href: 'selflink' }, + }, }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', @@ -73,7 +74,7 @@ describe('MetadataFieldDataService', () => { it('should call searchBy with the correct arguments', () => { metadataFieldService.findBySchema(schema); const expectedOptions = Object.assign(new FindListOptions(), { - searchParams: [new RequestParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)], }); expect(metadataFieldService.searchBy).toHaveBeenCalledWith('bySchema', expectedOptions, true, true); }); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index d05e3533d3..aa79ef517e 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,33 +1,43 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { take } 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 { ObjectCacheService } from '../cache/object-cache.service'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PutData, + PutDataImpl, +} from './base/put-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; -import { MetadataField } from '../metadata/metadata-field.model'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { RequestParam } from '../cache/models/request-param.model'; -import { FindListOptions } from './find-list-options.model'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { PutData, PutDataImpl } from './base/put-data'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { NoContent } from '../shared/NoContent.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint */ -@Injectable() -@dataService(METADATA_FIELD) +@Injectable({ providedIn: 'root' }) export class MetadataFieldDataService extends IdentifiableDataService implements CreateData, PutData, DeleteData, SearchData { private createData: CreateData; private searchData: SearchData; diff --git a/src/app/core/data/metadata-schema-data.service.spec.ts b/src/app/core/data/metadata-schema-data.service.spec.ts index 1bcf4e1104..02fbc016e7 100644 --- a/src/app/core/data/metadata-schema-data.service.spec.ts +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -1,16 +1,20 @@ -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { MetadataSchemaDataService } from './metadata-schema-data.service'; import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { CreateRequest, PutRequest } from './request.models'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; + import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestResponse } from '../cache/response.models'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { MetadataSchemaDataService } from './metadata-schema-data.service'; +import { + CreateRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; describe('MetadataSchemaDataService', () => { let metadataSchemaService: MetadataSchemaDataService; @@ -61,8 +65,8 @@ describe('MetadataSchemaDataService', () => { prefix: 'dc', namespace: 'namespace', _links: { - self: { href: 'selflink' } - } + self: { href: 'selflink' }, + }, }); }); @@ -78,7 +82,7 @@ describe('MetadataSchemaDataService', () => { describe('called with an existing metadata schema', () => { beforeEach(() => { schema = Object.assign(schema, { - id: 'id-of-existing-schema' + id: 'id-of-existing-schema', }); }); diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 6bd633b8c6..e893cb5404 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,31 +1,41 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } 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 { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { Observable } from 'rxjs'; -import { hasValue } from '../../shared/empty.util'; -import { tap } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; -import { PutData, PutDataImpl } from './base/put-data'; -import { CreateData, CreateDataImpl } from './base/create-data'; import { NoContent } from '../shared/NoContent.model'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { FindListOptions } from './find-list-options.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { PaginatedList } from './paginated-list.model'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { dataService } from './base/data-service.decorator'; +import { + PutData, + PutDataImpl, +} from './base/put-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ -@Injectable() -@dataService(METADATA_SCHEMA) +@Injectable({ providedIn: 'root' }) export class MetadataSchemaDataService extends IdentifiableDataService implements FindAllData, DeleteData { private createData: CreateData; private findAllData: FindAllData; diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index e46e319149..f824be7f56 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -1,21 +1,25 @@ import { Injectable } from '@angular/core'; + +import { hasValue } from '../../shared/empty.util'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { hasValue } from '../../shared/empty.util'; -import { SearchObjects } from '../../shared/search/models/search-objects.model'; -import { MetadataMap, MetadataValue } from '../shared/metadata.models'; +import { + MetadataMap, + MetadataValue, +} from '../shared/metadata.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse { // fallback for unexpected empty response const emptyPayload = { _embedded: { - objects: [] - } + objects: [], + }, }; const payload = data.payload._embedded.searchResult || emptyPayload; const hitHighlights: MetadataMap[] = payload._embedded.objects @@ -26,7 +30,7 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer for (const key of Object.keys(hhObject)) { const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), - language: null + language: null, }); mdMap[key] = [value]; } @@ -46,7 +50,7 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer .map((object, index) => Object.assign({}, object, { indexableObject: dsoSelfLinks[index], hitHighlights: hitHighlights[index], - _embedded: this.filterEmbeddedObjects(object) + _embedded: this.filterEmbeddedObjects(object), })); payload.objects = objects; const deserialized: any = new DSpaceSerializer(SearchObjects).deserialize(payload); @@ -65,8 +69,8 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer .reduce((obj, key) => { obj[key] = object._embedded.indexableObject._embedded[key]; return obj; - }, {}) - }) + }, {}), + }), }); } else { return object; 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 0000000000..e336843505 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.spec.ts @@ -0,0 +1,88 @@ +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotifyRequestsStatusDataService } from './notify-services-status-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; +import { RequestEntryState } from './request-entry-state.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 0000000000..deeaad967a --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { + map, + Observable, + take, +} from 'rxjs'; + +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +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 { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; +import { RequestService } from './request.service'; + +@Injectable({ providedIn: 'root' }) +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/object-updates/field-update.model.ts b/src/app/core/data/object-updates/field-update.model.ts index 47b6782471..71ae3bb68f 100644 --- a/src/app/core/data/object-updates/field-update.model.ts +++ b/src/app/core/data/object-updates/field-update.model.ts @@ -1,5 +1,5 @@ -import { Identifiable } from './identifiable.model'; import { FieldChangeType } from './field-change-type.model'; +import { Identifiable } from './identifiable.model'; /** * The state of a single field update diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 615dedbaf9..8a95a7827c 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,11 +1,12 @@ /* eslint-disable max-classes-per-file */ -import { type } from '../../../shared/ngrx/type'; import { Action } from '@ngrx/store'; + +import { type } from '../../../shared/ngrx/type'; import { INotification } from '../../../shared/notifications/models/notification.model'; -import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; import { FieldChangeType } from './field-change-type.model'; +import { Identifiable } from './identifiable.model'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * The list of ObjectUpdatesAction type definitions @@ -20,7 +21,7 @@ export const ObjectUpdatesActionTypes = { REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'), - REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), }; @@ -49,7 +50,7 @@ export class InitializeFieldsAction implements Action { url: string, fields: Identifiable[], lastModified: Date, - patchOperationService?: GenericConstructor + patchOperationService?: GenericConstructor, ) { this.payload = { url, fields, lastModified, patchOperationService }; } @@ -113,7 +114,7 @@ export class SelectVirtualMetadataAction implements Action { uuid: string, select: boolean, ) { - this.payload = { url, source, uuid, select: select}; + this.payload = { url, source, uuid, select: select }; } } @@ -193,7 +194,7 @@ export class DiscardObjectUpdatesAction implements Action { constructor( url: string, notification: INotification, - discardAll = false + discardAll = false, ) { this.payload = { url, notification, discardAll }; } @@ -215,7 +216,7 @@ export class ReinstateObjectUpdatesAction implements Action { * the unique url of the page for which the changes should be reinstated */ constructor( - url: string + url: string, ) { this.payload = { url }; } @@ -237,7 +238,7 @@ export class RemoveObjectUpdatesAction implements Action { * the unique url of the page for which the changes should be removed */ constructor( - url: string + url: string, ) { this.payload = { url }; } @@ -269,7 +270,7 @@ export class RemoveFieldUpdateAction implements Action { */ constructor( url: string, - uuid: string + uuid: string, ) { this.payload = { url, uuid }; } diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts index ffd20a7300..10f37d78cb 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -1,24 +1,35 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Observable, Subject } from 'rxjs'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; +import { Action } from '@ngrx/store'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + Subject, +} from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { + INotification, + Notification, +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { ObjectUpdatesEffects } from './object-updates.effects'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction + RemoveObjectUpdatesAction, } from './object-updates.actions'; -import { - INotification, - Notification -} from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; -import { filter } from 'rxjs/operators'; -import { hasValue } from '../../../shared/empty.util'; -import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { ObjectUpdatesEffects } from './object-updates.effects'; describe('ObjectUpdatesEffects', () => { let updatesEffects: ObjectUpdatesEffects; @@ -31,13 +42,7 @@ describe('ObjectUpdatesEffects', () => { providers: [ ObjectUpdatesEffects, provideMockActions(() => actions), - { - provide: NotificationsService, - useValue: { - remove: (notification) => { /* empty */ - } - } - }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], }); })); @@ -59,7 +64,6 @@ describe('ObjectUpdatesEffects', () => { action = new RemoveObjectUpdatesAction(testURL); }); it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { - action = new RemoveObjectUpdatesAction(testURL); actions = hot('--a-', { a: action }); (updatesEffects as any).actionMap$[testURL].subscribe((act) => emittedAction = act); const expected = cold('--b-', { b: undefined }); @@ -81,14 +85,19 @@ describe('ObjectUpdatesEffects', () => { removeAction = new RemoveObjectUpdatesAction(testURL); }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( - filter(((action) => hasValue(action)))) - .subscribe((t) => { - expect(t).toEqual(removeAction); - } - ) - ; + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('a', { a: undefined })); + + expect(emittedAction).toEqual(removeAction); }); }); @@ -98,12 +107,24 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return an action with type NO_ACTION', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { - expect(t).toEqual(new NoOpAction()); - } - ); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new ReinstateObjectUpdatesAction(testURL), + }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + take(2), + ).subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); @@ -113,12 +134,22 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new RemoveFieldUpdateAction(testURL, testUUID), + }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => - expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) - ); + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); }); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 1dfdc95f23..5ef86dbbec 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -1,24 +1,43 @@ import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { + of as observableOf, + race as observableRace, + Subject, +} from 'rxjs'; +import { + delay, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { + NotificationsActions, + NotificationsActionTypes, + RemoveNotificationAction, +} from '../../../shared/notifications/notifications.actions'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction, - RemoveObjectUpdatesAction + RemoveObjectUpdatesAction, } from './object-updates.actions'; -import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; -import { of as observableOf, race as observableRace, Subject } from 'rxjs'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { INotification } from '../../../shared/notifications/models/notification.model'; -import { - NotificationsActions, - NotificationsActionTypes, - RemoveNotificationAction -} from '../../../shared/notifications/notifications.actions'; -import { Action } from '@ngrx/store'; -import { NoOpAction } from '../../../shared/ngrx/no-op.action'; /** * NGRX effects for ObjectUpdatesActions @@ -52,7 +71,7 @@ export class ObjectUpdatesEffects { /** * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key */ - mapLastActions$ = createEffect(() => this.actions$ + mapLastActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { @@ -63,23 +82,23 @@ export class ObjectUpdatesEffects { } this.actionMap$[url].next(action); } - }) + }), ), { dispatch: false }); /** * Effect that makes sure all last fired NotificationActions are stored in the notification map of this service, with the id as their key */ - mapLastNotificationActions$ = createEffect(() => this.actions$ + mapLastNotificationActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(NotificationsActionTypes)), map((action: RemoveNotificationAction) => { - const id: string = action.payload.id || action.payload || this.allIdentifier; - if (hasNoValue(this.notificationActionMap$[id])) { - this.notificationActionMap$[id] = new Subject(); - } - this.notificationActionMap$[id].next(action); + const id: string = action.payload.id || action.payload || this.allIdentifier; + if (hasNoValue(this.notificationActionMap$[id])) { + this.notificationActionMap$[id] = new Subject(); } - ) + this.notificationActionMap$[id].next(action); + }, + ), ), { dispatch: false }); /** @@ -88,52 +107,52 @@ export class ObjectUpdatesEffects { * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned */ - removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ + removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ .pipe( ofType(ObjectUpdatesActionTypes.DISCARD), switchMap((action: DiscardObjectUpdatesAction) => { - const url: string = action.payload.url; - const notification: INotification = action.payload.notification; - const timeOut = notification.options.timeOut; + const url: string = action.payload.url; + const notification: INotification = action.payload.notification; + const timeOut = notification.options.timeOut; - let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); - if (action.payload.discardAll) { - removeAction = new RemoveAllObjectUpdatesAction(); - } - - return observableRace( - // Either wait for the delay and perform a remove action - observableOf(removeAction).pipe(delay(timeOut)), - // Or wait for a a user action - this.actionMap$[url].pipe( - take(1), - tap(() => { - this.notificationsService.remove(notification); - }), - map((updateAction: ObjectUpdatesAction) => { - if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { - // If someone reinstated, do nothing, just let the reinstating happen - return new NoOpAction(); - } - // If someone performed another action, assume the user does not want to reinstate and remove all changes - return removeAction; - }) - ), - this.notificationActionMap$[notification.id].pipe( - filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), - map(() => { - return removeAction; - }) - ), - this.notificationActionMap$[this.allIdentifier].pipe( - filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), - map(() => { - return removeAction; - }) - ) - ); + let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); + if (action.payload.discardAll) { + removeAction = new RemoveAllObjectUpdatesAction(); } - ) + + return observableRace( + // Either wait for the delay and perform a remove action + observableOf(removeAction).pipe(delay(timeOut)), + // Or wait for a a user action + this.actionMap$[url].pipe( + take(1), + tap(() => { + this.notificationsService.remove(notification); + }), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return new NoOpAction(); + } + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return removeAction; + }), + ), + this.notificationActionMap$[notification.id].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), + map(() => { + return removeAction; + }), + ), + this.notificationActionMap$[this.allIdentifier].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), + map(() => { + return removeAction; + }), + ), + ); + }, + ), )); constructor(private actions$: Actions, diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index 08944a073f..1f2a15769b 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -1,5 +1,8 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; + +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -10,11 +13,13 @@ import { RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, - SetValidFieldUpdateAction + SetValidFieldUpdateAction, } from './object-updates.actions'; -import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { FieldChangeType } from './field-change-type.model'; +import { + OBJECT_UPDATES_TRASH_PATH, + objectUpdatesReducer, + ObjectUpdatesState, +} from './object-updates.reducer'; class NullAction extends RemoveFieldUpdateAction { type = null; @@ -29,26 +34,26 @@ const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', key: 'dc.contributor.author', language: null, - value: 'Smith, John' + value: 'Smith, John', }; const identifiable1update = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', key: 'dc.contributor.author', language: null, - value: 'Smith, James' + value: 'Smith, James', }; const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', key: 'dc.title', language: null, - value: 'New title' + value: 'New title', }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', key: 'dc.description.abstract', language: null, - value: 'Unchanged value' + value: 'Unchanged value', }; const relationship: Relationship = Object.assign(new Relationship(), { uuid: 'test relationship uuid' }); @@ -56,65 +61,62 @@ const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; const url = 'test-object.url/edit'; describe('objectUpdatesReducer', () => { - const testState = { + const testState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: false + isValid: false, }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, - } + }, }; - const discardedTestState = { + const discardedTestState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, }, [url + OBJECT_UPDATES_TRASH_PATH]: { @@ -122,35 +124,32 @@ describe('objectUpdatesReducer', () => { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: false + isValid: false, }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, - } + }, }; deepFreeze(testState); @@ -173,48 +172,80 @@ describe('objectUpdatesReducer', () => { const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { const action = new SetEditableFieldUpdateAction(url, uuid, false); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_FIELD action without affecting the previous state', () => { const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the DISCARD action without affecting the previous state', () => { const action = new DiscardObjectUpdatesAction(url, null); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REINSTATE action without affecting the previous state', () => { const action = new ReinstateObjectUpdatesAction(url); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE_FIELD action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { @@ -226,19 +257,19 @@ describe('objectUpdatesReducer', () => { [identifiable1.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, }, fieldUpdates: {}, virtualMetadataSources: {}, lastModified: modDate, - patchOperationService: undefined - } + patchOperationService: undefined, + }, }; const newState = objectUpdatesReducer(testState, action); expect(newState).toEqual(expectedState); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 14bacc52db..e014889850 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,3 +1,14 @@ +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { Item } from '../../shared/item.model'; +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; +import { FieldChangeType } from './field-change-type.model'; +import { FieldUpdates } from './field-updates.model'; +import { Identifiable } from './identifiable.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -11,15 +22,7 @@ import { SetEditableFieldUpdateAction, SetValidFieldUpdateAction, } from './object-updates.actions'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; -import { Item } from '../../shared/item.model'; -import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; -import { FieldUpdates } from './field-updates.model'; -import { FieldChangeType } from './field-change-type.model'; /** * Path where discarded objects are saved @@ -77,7 +80,7 @@ export interface DeleteRelationship extends RelationshipIdentifiable { */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates; + fieldUpdates?: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; patchOperationService?: GenericConstructor; @@ -164,7 +167,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { { fieldUpdates: {} }, { virtualMetadataSources: {} }, { lastModified: lastModifiedServer }, - { patchOperationService } + { patchOperationService }, ); return Object.assign({}, state, { [url]: newPageState }); } @@ -178,7 +181,7 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { const url: string = action.payload.url; const field: Identifiable = action.payload.field; const changeType: FieldChangeType = action.payload.changeType; - const pageState: ObjectUpdatesEntry = state[url] || {fieldUpdates: {}}; + const pageState: ObjectUpdatesEntry = state[url] || { fieldUpdates: {} }; let states = pageState.fieldStates; if (changeType === FieldChangeType.ADD) { @@ -231,7 +234,7 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) const newPageState = Object.assign( {}, pageState, - {virtualMetadataSources: virtualMetadataSources}, + { virtualMetadataSources: virtualMetadataSources }, ); return Object.assign( @@ -239,7 +242,7 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) state, { [url]: newPageState, - } + }, ); } @@ -279,7 +282,7 @@ function discardObjectUpdatesFor(url: string, state: any) { const discardedPageState = Object.assign({}, pageState, { fieldUpdates: {}, - fieldStates: newFieldStates + fieldStates: newFieldStates, }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -357,7 +360,7 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { } newPageState = Object.assign({}, state[url], { fieldUpdates: newUpdates, - fieldStates: newFieldStates + fieldStates: newFieldStates, }); } return Object.assign({}, state, { [url]: newPageState }); diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 9cf856f03a..6602cda080 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -1,21 +1,23 @@ +import { Injector } from '@angular/core'; import { Store } from '@ngrx/store'; -import { ObjectUpdatesService } from './object-updates.service'; +import { createMockStore } from '@ngrx/store/testing'; +import { of as observableOf } from 'rxjs'; + +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { CoreState } from '../../core-state.model'; +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; import { DiscardObjectUpdatesAction, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, - SetEditableFieldUpdateAction + SetEditableFieldUpdateAction, } from './object-updates.actions'; -import { of as observableOf } from 'rxjs'; -import { Notification } from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { Injector } from '@angular/core'; -import { FieldChangeType } from './field-change-type.model'; -import { CoreState } from '../../core-state.model'; +import { ObjectUpdatesService } from './object-updates.service'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -31,7 +33,7 @@ describe('ObjectUpdatesService', () => { const fieldUpdates = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, }; const modDate = new Date(2010, 2, 11); @@ -46,15 +48,15 @@ describe('ObjectUpdatesService', () => { }; patchOperationService = jasmine.createSpyObj('patchOperationService', { - fieldUpdatesToPatchOperations: [] + fieldUpdatesToPatchOperations: [], }); const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationService + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationService, }; - store = new Store(undefined, undefined, undefined); + store = createMockStore({}); spyOn(store, 'dispatch'); injector = jasmine.createSpyObj('injector', { - get: patchOperationService + get: patchOperationService, }); service = new ObjectUpdatesService(store, injector); @@ -80,7 +82,7 @@ describe('ObjectUpdatesService', () => { const expectedResult = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, }; result$.subscribe((result) => { @@ -96,7 +98,7 @@ describe('ObjectUpdatesService', () => { const expectedResult = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, - [identifiable2.uuid]: { field: identifiable2, changeType: undefined } + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, }; result$.subscribe((result) => { diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 2fb6d47d31..1f036ddfb1 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,14 +1,36 @@ -import { Injectable, Injector } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { coreSelector } from '../../core.selectors'; import { - FieldState, - OBJECT_UPDATES_TRASH_PATH, - ObjectUpdatesEntry, - ObjectUpdatesState, - VirtualMetadataSource -} from './object-updates.reducer'; + Injectable, + Injector, +} from '@angular/core'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, +} from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { coreSelector } from '../../core.selectors'; +import { CoreState } from '../../core-state.model'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { FieldChangeType } from './field-change-type.model'; +import { FieldUpdates } from './field-updates.model'; +import { Identifiable } from './identifiable.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -17,24 +39,16 @@ import { RemoveFieldUpdateAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, - SetValidFieldUpdateAction + SetValidFieldUpdateAction, } from './object-updates.actions'; -import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { - hasNoValue, - hasValue, - isEmpty, - isNotEmpty, - hasValueOperator -} from '../../../shared/empty.util'; -import { INotification } from '../../../shared/notifications/models/notification.model'; -import { Operation } from 'fast-json-patch'; + FieldState, + OBJECT_UPDATES_TRASH_PATH, + ObjectUpdatesEntry, + ObjectUpdatesState, + VirtualMetadataSource, +} from './object-updates.reducer'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; -import { FieldUpdates } from './field-updates.model'; -import { FieldChangeType } from './field-change-type.model'; -import { CoreState } from '../../core-state.model'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -55,7 +69,7 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel /** * Service that dispatches and reads from the ObjectUpdates' state in the store */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ObjectUpdatesService { constructor(private store: Store, private injector: Injector) { @@ -122,7 +136,7 @@ export class ObjectUpdatesService { fieldUpdates[uuid] = fieldUpdatesExclusive[uuid]; }); return fieldUpdates; - }) + }), ); }), ); @@ -139,16 +153,16 @@ export class ObjectUpdatesService { return objectUpdates.pipe( hasValueOperator(), map((objectEntry) => { - const fieldUpdates: FieldUpdates = {}; - for (const object of initialFields) { - let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; - if (isEmpty(fieldUpdate)) { - fieldUpdate = { field: object, changeType: undefined }; + const fieldUpdates: FieldUpdates = {}; + for (const object of initialFields) { + let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; + if (isEmpty(fieldUpdate)) { + fieldUpdate = { field: object, changeType: undefined }; + } + fieldUpdates[object.uuid] = fieldUpdate; } - fieldUpdates[object.uuid] = fieldUpdate; - } - return fieldUpdates; - })); + return fieldUpdates; + })); } /** @@ -161,7 +175,7 @@ export class ObjectUpdatesService { return fieldState$.pipe( filter((fieldState) => hasValue(fieldState)), map((fieldState) => fieldState.editable), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -175,7 +189,7 @@ export class ObjectUpdatesService { return fieldState$.pipe( filter((fieldState) => hasValue(fieldState)), map((fieldState) => fieldState.isValid), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -189,7 +203,7 @@ export class ObjectUpdatesService { map((entry: ObjectUpdatesEntry) => { return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0; }), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -234,7 +248,7 @@ export class ObjectUpdatesService { .pipe( select(virtualMetadataSourceSelector(url, relationship)), map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), - ); + ); } /** @@ -367,7 +381,7 @@ export class ObjectUpdatesService { patch = this.injector.get(entry.patchOperationService).fieldUpdatesToPatchOperations(entry.fieldUpdates); } return patch; - }) + }), ); } } diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts index db46426b79..ddf38dd2bb 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -1,8 +1,9 @@ -import { MetadataPatchOperationService } from './metadata-patch-operation.service'; import { Operation } from 'fast-json-patch'; + import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { FieldUpdates } from '../field-updates.model'; import { FieldChangeType } from '../field-change-type.model'; +import { FieldUpdates } from '../field-updates.model'; +import { MetadataPatchOperationService } from './metadata-patch-operation.service'; describe('MetadataPatchOperationService', () => { let service: MetadataPatchOperationService; @@ -23,13 +24,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -46,13 +47,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Added title', - place: 0 + place: 0, }), - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }); expected = [ - { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] } + { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -69,13 +70,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Changed title', - place: 0 + place: 0, }), - changeType: FieldChangeType.UPDATE - } + changeType: FieldChangeType.UPDATE, + }, }); expected = [ - { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } } + { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -92,31 +93,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/0' }, { op: 'remove', path: '/metadata/dc.title/0' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -133,31 +134,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/2' }, { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -174,31 +175,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -215,31 +216,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third changed title', - place: 2 + place: 2, }), - changeType: FieldChangeType.UPDATE + changeType: FieldChangeType.UPDATE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts index 33e9129a9d..b6dccb759b 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -1,21 +1,22 @@ -import { PatchOperationService } from './patch-operation.service'; -import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { Operation } from 'fast-json-patch'; import { Injectable } from '@angular/core'; -import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + import { hasValue } from '../../../../shared/empty.util'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldChangeType } from '../field-change-type.model'; +import { FieldUpdates } from '../field-updates.model'; import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model'; +import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model'; import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model'; -import { FieldUpdates } from '../field-updates.model'; -import { FieldChangeType } from '../field-change-type.model'; +import { PatchOperationService } from './patch-operation.service'; /** * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values * This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class MetadataPatchOperationService implements PatchOperationService { @@ -75,7 +76,7 @@ export class MetadataPatchOperationService implements PatchOperationService { const metadatum = update.field as MetadatumViewModel; const val = { value: metadatum.value, - language: metadatum.language + language: metadatum.language, }; let operation: MetadataPatchOperation; diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts index 7f9b1d772f..9242290c6b 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch add Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts index 962d53dfee..d80ec16cd1 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch move Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts index 61fbae1980..efaf61f381 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch remove Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts index e889bede0b..c2d9581293 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch replace Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts index 171c1d2a54..7e9c1087ff 100644 --- a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts @@ -1,4 +1,5 @@ import { Operation } from 'fast-json-patch'; + import { FieldUpdates } from '../field-updates.model'; /** diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index 415bfe234e..ef2819afd8 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -1,13 +1,22 @@ -import { PageInfo } from '../shared/page-info.model'; -import { hasValue, isEmpty, hasNoValue, isUndefined } from '../../shared/empty.util'; -import { HALResource } from '../shared/hal-resource.model'; -import { HALLink } from '../shared/hal-link.model'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { + hasNoValue, + hasValue, + isEmpty, + isUndefined, +} from '../../shared/empty.util'; import { typedObject } from '../cache/builders/build-decorators'; -import { PAGINATED_LIST } from './paginated-list.resource-type'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { PageInfo } from '../shared/page-info.model'; import { ResourceType } from '../shared/resource-type'; import { excludeFromEquals } from '../utilities/equals.decorators'; -import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { PAGINATED_LIST } from './paginated-list.resource-type'; /** * Factory function for a paginated list @@ -45,7 +54,7 @@ export const buildPaginatedList = (pageInfo: PageInfo, page: T[], normalized } result._links = Object.assign({}, _links, pageInfo._links, { - page: pageLinks + page: pageLinks, }); if (!normalized || isUndefined(pageLinks)) { diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index fbebe75b2b..9bf91121cc 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,5 +1,5 @@ -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ParsedResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { RestRequest } from './rest-request.model'; export interface ResponseParsingService { diff --git a/src/app/core/data/primary-bitstream.service.spec.ts b/src/app/core/data/primary-bitstream.service.spec.ts index 00d6d7f03c..6a9c89f796 100644 --- a/src/app/core/data/primary-bitstream.service.spec.ts +++ b/src/app/core/data/primary-bitstream.service.spec.ts @@ -1,21 +1,31 @@ -import { ObjectCacheService } from '../cache/object-cache.service'; -import { RequestService } from './request.service'; -import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { PrimaryBitstreamService } from './primary-bitstream.service'; -import { BundleDataService } from './bundle-data.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { CreateRequest, DeleteRequest, PostRequest, PutRequest } from './request.models'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Bundle } from '../shared/bundle.model'; import { getTestScheduler } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BundleDataService } from './bundle-data.service'; +import { PrimaryBitstreamService } from './primary-bitstream.service'; +import { + CreateRequest, + DeleteRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; + describe('PrimaryBitstreamService', () => { let service: PrimaryBitstreamService; let objectCache: ObjectCacheService; @@ -28,8 +38,8 @@ describe('PrimaryBitstreamService', () => { const bitstream = Object.assign(new Bitstream(), { uuid: 'fake-bitstream', _links: { - self: { href: 'fake-bitstream-self' } - } + self: { href: 'fake-bitstream-self' }, + }, }); const bundle = Object.assign(new Bundle(), { @@ -37,21 +47,21 @@ describe('PrimaryBitstreamService', () => { _links: { self: { href: 'fake-bundle-self' }, primaryBitstream: { href: 'fake-primary-bitstream-self' }, - } + }, }); const url = 'fake-bitstream-url'; beforeEach(() => { objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); requestService = getMockRequestService(); halService = Object.assign(new HALEndpointServiceStub(url)); rdbService = getMockRemoteDataBuildService(); notificationService = new NotificationsServiceStub() as any; - bundleDataService = jasmine.createSpyObj('bundleDataService', {'findByHref': createSuccessfulRemoteDataObject$(bundle)}); + bundleDataService = jasmine.createSpyObj('bundleDataService', { 'findByHref': createSuccessfulRemoteDataObject$(bundle) }); service = new PrimaryBitstreamService(requestService, rdbService, objectCache, halService, notificationService, bundleDataService); }); @@ -96,7 +106,7 @@ describe('PrimaryBitstreamService', () => { expect((service as any).createAndSendRequest).toHaveBeenCalledWith( PostRequest, bundle._links.primaryBitstream.href, - bitstream.self + bitstream.self, ); }); }); @@ -113,7 +123,7 @@ describe('PrimaryBitstreamService', () => { expect((service as any).createAndSendRequest).toHaveBeenCalledWith( PutRequest, bundle._links.primaryBitstream.href, - bitstream.self + bitstream.self, ); }); }); @@ -121,12 +131,12 @@ describe('PrimaryBitstreamService', () => { const testBundle = Object.assign(new Bundle(), { _links: { self: { - href: 'test-href' + href: 'test-href', }, primaryBitstream: { - href: 'test-primaryBitstream-href' - } - } + href: 'test-primaryBitstream-href', + }, + }, }); describe('when the delete request succeeds', () => { diff --git a/src/app/core/data/primary-bitstream.service.ts b/src/app/core/data/primary-bitstream.service.ts index 488cb5d22e..a5367e67ed 100644 --- a/src/app/core/data/primary-bitstream.service.ts +++ b/src/app/core/data/primary-bitstream.service.ts @@ -1,20 +1,28 @@ -import { Bitstream } from '../shared/bitstream.model'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; +import { + Observable, + switchMap, +} from 'rxjs'; + +import { NotificationsService } from '../../shared/notifications/notifications.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 { Observable, switchMap } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { Bundle } from '../shared/bundle.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { HttpHeaders } from '@angular/common/http'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { PutRequest, PostRequest, DeleteRequest } from './request.models'; -import { getAllCompletedRemoteData } from '../shared/operators'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NoContent } from '../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../shared/operators'; import { BundleDataService } from './bundle-data.service'; +import { RemoteData } from './remote-data'; +import { + DeleteRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -63,8 +71,8 @@ export class PrimaryBitstreamService { requestId, endpointURL, primaryBitstreamSelfLink, - this.getHttpOptions() - ); + this.getHttpOptions(), + ); this.requestService.send(request); @@ -81,7 +89,7 @@ export class PrimaryBitstreamService { return this.createAndSendRequest( PostRequest, bundle._links.primaryBitstream.href, - primaryBitstream.self + primaryBitstream.self, ) as Observable>; } @@ -95,7 +103,7 @@ export class PrimaryBitstreamService { return this.createAndSendRequest( PutRequest, bundle._links.primaryBitstream.href, - primaryBitstream.self + primaryBitstream.self, ) as Observable>; } @@ -107,12 +115,12 @@ export class PrimaryBitstreamService { delete(bundle: Bundle): Observable> { return this.createAndSendRequest( DeleteRequest, - bundle._links.primaryBitstream.href + bundle._links.primaryBitstream.href, ).pipe( getAllCompletedRemoteData(), switchMap((rd: RemoteData) => { return this.bundleDataService.findByHref(bundle.self, rd.hasFailed); - }) + }), ); } diff --git a/src/app/core/data/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts index 88e5bd5791..217567776c 100644 --- a/src/app/core/data/processes/process-data.service.spec.ts +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -6,14 +6,197 @@ * http://www.dspace.org/license/ */ -import { testFindAllDataImplementation } from '../base/find-all-data.spec'; -import { ProcessDataService } from './process-data.service'; +import { + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { ReducerManager } from '@ngrx/store'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessStatus } from '../../../process-page/processes/process-status.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.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 { testDeleteDataImplementation } from '../base/delete-data.spec'; +import { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { BitstreamFormatDataService } from '../bitstream-format-data.service'; +import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; +import { + ProcessDataService, + TIMER_FACTORY, +} from './process-data.service'; describe('ProcessDataService', () => { + let testScheduler; + + const mockTimer = (fn: () => any, interval: number) => { + fn(); + return 555; + }; + describe('composition', () => { - const initService = () => new ProcessDataService(null, null, null, null, null, null); + const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null); testFindAllDataImplementation(initService); testDeleteDataImplementation(initService); + testSearchDataImplementation(initService); + }); + + let requestService = getMockRequestService(); + let processDataService; + let remoteDataBuildService; + + describe('autoRefreshUntilCompletion', () => { + beforeEach(waitForAsync(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: null }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ], + }); + + processDataService = TestBed.inject(ProcessDataService); + spyOn(processDataService, 'invalidateByHref'); + })); + + it('should not do any polling when the process is already completed', () => { + testScheduler.run(({ cold, expectObservable }) => { + let completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('c', { + 'c': completedProcessRD, + }), + ); + + let process$ = processDataService.autoRefreshUntilCompletion('instantly'); + expectObservable(process$).toBe('c', { + c: completedProcessRD, + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).not.toHaveBeenCalled(); + }); + + it('should poll until a process completes', () => { + testScheduler.run(({ cold, expectObservable }) => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123', + }, + }, + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + const completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess); + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD, + }), + ); + + let process$ = processDataService.autoRefreshUntilCompletion('foo', 100); + expectObservable(process$).toBe('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD, + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1); + }); + }); + + describe('autoRefreshingSearchBy', () => { + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ], + }); + + processDataService = TestBed.inject(ProcessDataService); + })); + + it('should refresh after the specified interval', fakeAsync(() => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123', + }, + }, + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + + const runningProcessPagination: PaginatedList = Object.assign(new PaginatedList(), { + page: [runningProcess], + _links: { + self: { + href: 'https://rest.api/processesList/456', + }, + }, + }); + + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcessPagination); + + spyOn(processDataService, 'searchBy').and.returnValue( + of(runningProcessRD), + ); + + expect(processDataService.searchBy).toHaveBeenCalledTimes(0); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(0); + + let sub = processDataService.autoRefreshingSearchBy('id', 'byProperty', new FindListOptions(), 200).subscribe(); + expect(processDataService.searchBy).toHaveBeenCalledTimes(1); + + tick(250); + + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(1); + + sub.unsubscribe(); + })); }); }); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 3bf34eb650..b39a500c80 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,30 +1,66 @@ -import { Injectable } from '@angular/core'; -import { RequestService } from '../request.service'; +import { + Inject, + Injectable, + InjectionToken, + NgZone, +} from '@angular/core'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + find, + switchMap, +} from 'rxjs/operators'; +import { ProcessStatus } from 'src/app/process-page/processes/process-status.model'; + +import { Process } from '../../../process-page/processes/process.model'; +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 { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { Process } from '../../../process-page/processes/process.model'; -import { PROCESS } from '../../../process-page/processes/process.resource-type'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; -import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; -import { RemoteData } from '../remote-data'; -import { BitstreamDataService } from '../bitstream-data.service'; -import { IdentifiableDataService } from '../base/identifiable-data.service'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { dataService } from '../base/data-service.decorator'; -import { DeleteData, DeleteDataImpl } from '../base/delete-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NoContent } from '../../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../../shared/operators'; +import { + DeleteData, + DeleteDataImpl, +} from '../base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from '../base/find-all-data'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from '../base/search-data'; +import { BitstreamDataService } from '../bitstream-data.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; -@Injectable() -@dataService(PROCESS) -export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { +/** + * Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during + * testing. (fakeAsync isn't working for this case) + */ +export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', { + providedIn: 'root', + factory: () => setTimeout, +}); + +@Injectable({ providedIn: 'root' }) +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData, SearchData { private findAllData: FindAllData; private deleteData: DeleteData; + private searchData: SearchData; + protected activelyBeingPolled: Map = new Map(); + protected subs: Map = new Map(); constructor( protected requestService: RequestService, @@ -33,11 +69,30 @@ export class ProcessDataService extends IdentifiableDataService impleme protected halService: HALEndpointService, protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, + protected zone: NgZone, + @Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout, ) { super('processes', 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.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return true if the given process has the given status + * @protected + */ + protected static statusIs(process: Process, status: ProcessStatus): boolean { + return hasValue(process) && process.processStatus === status; + } + + /** + * Return true if the given process has the status COMPLETED or FAILED + */ + public static hasCompletedOrFailed(process: Process): boolean { + return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) || + ProcessDataService.statusIs(process, ProcessStatus.FAILED); } /** @@ -46,7 +101,7 @@ export class ProcessDataService extends IdentifiableDataService impleme */ getFilesEndpoint(processId: string): Observable { return this.getBrowseEndpoint().pipe( - switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)) + switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)), ); } @@ -77,6 +132,71 @@ export class ProcessDataService extends IdentifiableDataService impleme return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * @param searchMethod The search method for the Process + * @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 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 automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * @param id The id for this auto-refreshing search. Used to stop + * auto-refreshing afterwards, and ensure we're not + * auto-refreshing the same thing multiple times. + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param pollingIntervalInMs The interval by which the search will be repeated + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes every interval + */ + autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig[]): Observable>> { + + const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + + const sub = result$.pipe( + filter(() => + !this.activelyBeingPolled.has(id), + ), + ).subscribe((processListRd: RemoteData>) => { + this.clearCurrentTimeout(id); + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(id); + this.requestService.setStaleByHrefSubstring(processListRd.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(id, nextTimeout); + }); + + this.subs.set(id, sub); + + return result$; + } + + /** + * Stop auto-refreshing the request with the given id + * @param id the id of the request to stop automatically refreshing + */ + stopAutoRefreshing(id: string) { + this.clearCurrentTimeout(id); + if (hasValue(this.subs.get(id))) { + this.subs.get(id).unsubscribe(); + this.subs.delete(id); + } + } + /** * Delete an existing object on the server * @param objectId The id of the object to be removed @@ -101,4 +221,74 @@ export class ProcessDataService extends IdentifiableDataService impleme public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Clear the timeout for the given id, if that timeout exists + * @protected + */ + protected clearCurrentTimeout(id: string): void { + const timeout = this.activelyBeingPolled.get(id); + if (hasValue(timeout)) { + clearTimeout(timeout); + } + this.activelyBeingPolled.delete(id); + } + + /** + * Poll the process with the given ID, using the given interval, until that process either + * completes successfully or fails + * + * Return an Observable for the Process. Note that this will also emit while the + * process is still running. It will only emit again when the process (not the RemoteData!) changes + * status. That makes it more convenient to retrieve that process for a component: you can replace + * a findByID call with this method, rather than having to do a separate findById, and then call + * this method + * + * @param processId The ID of the {@link Process} to poll + * @param pollingIntervalInMs The interval for how often the request needs to be polled + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be + * automatically resolved + */ + public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig[]): Observable> { + const process$: Observable> = this.findById(processId, true, true, ...linksToFollow) + .pipe( + getAllCompletedRemoteData(), + ); + + // Create a subscription that marks the data as stale if the process hasn't been completed and + // the polling interval time has been exceeded. + const sub = process$.pipe( + filter((processRD: RemoteData) => + !ProcessDataService.hasCompletedOrFailed(processRD.payload) && + !this.activelyBeingPolled.has(processId), + ), + ).subscribe((processRD: RemoteData) => { + this.clearCurrentTimeout(processId); + if (processRD.hasSucceeded) { + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(processId); + this.invalidateByHref(processRD.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(processId, nextTimeout); + } + }); + + this.subs.set(processId, sub); + + // When the process completes create a one off subscription (the `find` completes the + // observable) that unsubscribes the previous one, removes the processId from the list of + // processes being polled and clears any running timeouts + process$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)), + ).subscribe(() => { + this.stopAutoRefreshing(processId); + }); + + return process$.pipe( + distinctUntilChanged((previous: RemoteData, current: RemoteData) => + previous.payload?.processStatus === current.payload?.processStatus, + ), + ); + } } diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index d9c92cb1d2..e61da4db63 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,34 +1,38 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; +import { Script } from '../../../process-page/scripts/script.model'; +import { hasValue } from '../../../shared/empty.util'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { Script } from '../../../process-page/scripts/script.model'; -import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; -import { map, take } from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { + FindAllData, + FindAllDataImpl, +} from '../base/find-all-data'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; import { MultipartPostRequest } from '../request.models'; import { RequestService } from '../request.service'; -import { Observable } from 'rxjs'; -import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; -import { Process } from '../../../process-page/processes/process.model'; -import { hasValue } from '../../../shared/empty.util'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; import { RestRequest } from '../rest-request.model'; -import { IdentifiableDataService } from '../base/identifiable-data.service'; -import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { PaginatedList } from '../paginated-list.model'; -import { dataService } from '../base/data-service.decorator'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; export const BATCH_IMPORT_SCRIPT_NAME = 'import'; export const BATCH_EXPORT_SCRIPT_NAME = 'export'; -@Injectable() -@dataService(SCRIPT) +@Injectable({ providedIn: 'root' }) export class ScriptDataService extends IdentifiableDataService