diff --git a/.browserslistrc b/.browserslistrc index f8a421c330..427441dc93 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -2,10 +2,16 @@ # For additional information regarding the format and rule options, please see: # https://github.com/browserslist/browserslist#queries +# For the full list of supported browsers by the Angular framework, please see: +# https://angular.io/guide/browser-support + # You can see what browsers were selected by your queries by running: # npx browserslist -> 0.5% -last 2 versions +last 1 Chrome version +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major versions +last 2 iOS major versions Firefox ESR -not IE 9-11 # For IE 9-11 support, remove 'not'. +not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..b95b54b979 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,229 @@ +{ + "root": true, + "plugins": [ + "@typescript-eslint", + "@angular-eslint/eslint-plugin", + "eslint-plugin-import", + "eslint-plugin-jsdoc", + "eslint-plugin-deprecation", + "unused-imports", + "eslint-plugin-lodash" + ], + "overrides": [ + { + "files": [ + "*.ts" + ], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./cypress/tsconfig.json" + ], + "createDefaultProgram": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "max-classes-per-file": [ + "error", + 1 + ], + "comma-dangle": [ + "off", + "always-multiline" + ], + "eol-last": [ + "error", + "always" + ], + "no-console": [ + "error", + { + "allow": [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "debug", + "info", + "dirxml", + "error", + "groupCollapsed", + "Console", + "profile", + "profileEnd", + "timeStamp", + "context" + ] + } + ], + "curly": "error", + "brace-style": [ + "error", + "1tbs", + { + "allowSingleLine": true + } + ], + "eqeqeq": [ + "error", + "always", + { + "null": "ignore" + } + ], + "radix": "error", + "guard-for-in": "error", + "no-bitwise": "error", + "no-restricted-imports": "error", + "no-caller": "error", + "no-debugger": "error", + "no-redeclare": "error", + "no-eval": "error", + "no-fallthrough": "error", + "no-trailing-spaces": "error", + "space-infix-ops": "error", + "keyword-spacing": "error", + "no-var": "error", + "no-unused-expressions": [ + "error", + { + "allowTernary": true + } + ], + "prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint) + "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", + { + "type": "attribute", + "prefix": "ds", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "ds", + "style": "kebab-case" + } + ], + "@angular-eslint/pipe-prefix": [ + "error", + { + "prefixes": [ + "ds" + ] + } + ], + "@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", + + "@typescript-eslint/no-inferrable-types":[ + "error", + { + "ignoreParameters": true + } + ], + "@typescript-eslint/quotes": [ + "error", + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": true + } + ], + "@typescript-eslint/semi": "error", + "@typescript-eslint/no-shadow": "error", + "@typescript-eslint/dot-notation": "error", + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "property", + "format": null + } + ], + "@typescript-eslint/member-ordering": [ + "error", + { + "default": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "@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/no-floating-promises": "warn", + "@typescript-eslint/no-misused-promises": "warn", + "@typescript-eslint/restrict-plus-operands": "warn", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/require-await": "off", + + "deprecation/deprecation": "warn", + + "import/order": "off", + "import/no-deprecated": "warn", + "import/no-namespace": "error", + "unused-imports/no-unused-imports": "error", + "lodash/import-scope": [ + "error", + "method" + ] + } + }, + { + "files": [ + "*.html" + ], + "extends": [ + "plugin:@angular-eslint/template/recommended" + ], + "rules": { + // todo: re-enable & fix errors + "@angular-eslint/template/no-negated-async": "off", + "@angular-eslint/template/eqeqeq": "off" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..406640bfcc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# By default, auto detect text files and perform LF normalization +# This ensures code is always checked in with LF line endings +* text=auto + +# JS and TS files must always use LF for Angular tools to work +# Some Angular tools expect LF line endings, even on Windows. +# This ensures Windows always checks out these files with LF line endings +# We've copied many of these rules from https://github.com/angular/angular-cli/ +*.js eol=lf +*.ts eol=lf +*.json eol=lf +*.json5 eol=lf +*.css eol=lf +*.scss eol=lf +*.html eol=lf +*.svg eol=lf \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index be15b0a507..e50105b879 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ ## References _Add references/links to any related issues or PRs. These may include:_ -* Fixes #[issue-number] -* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this) +* Fixes #`issue-number` (if this fixes an issue ticket) +* Requires DSpace/DSpace#`pr-number` (if a REST API PR is required to test this) ## Description Short summary of changes (1-2 sentences). @@ -19,8 +19,10 @@ List of changes in this PR: _This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_ - [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible. -- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint` -- [ ] My PR doesn't introduce circular dependencies +- [ ] My PR passes [ESLint](https://eslint.org/) validation using `yarn lint` +- [ ] My PR doesn't introduce circular dependencies (verified via `yarn check-circ-deps`) - [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods. - [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide). -- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. +- [ ] If my PR includes new libraries/dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. +- [ ] If my PR includes new features or configurations, I've provided basic technical documentation in the PR itself. +- [ ] If my PR fixes an issue ticket, I've [linked them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 539fd740ee..f3b7aff689 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,34 +6,39 @@ name: Build # Run this Build for all pushes / PRs to current branch on: [push, pull_request] +permissions: + contents: read # to fetch code (actions/checkout) + jobs: tests: runs-on: ubuntu-latest env: # The ci step will test the dspace-angular code against DSpace REST. # Direct that step to utilize a DSpace REST service that has been started in docker. - DSPACE_REST_HOST: localhost + DSPACE_REST_HOST: 127.0.0.1 DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: '/server' DSPACE_REST_SSL: false + # Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+ + DSPACE_UI_HOST: 127.0.0.1 # When Chrome version is specified, we pin to a specific version of Chrome # Comment this out to use the latest release #CHROME_VERSION: "90.0.4430.212-1" strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [12.x, 14.x] + node-version: [16.x, 18.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v2 + uses: actions/checkout@v3 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -58,7 +63,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - name: Cache Yarn dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: # Cache entire Yarn cache directory (see previous step) path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -70,7 +75,10 @@ jobs: run: yarn install --frozen-lockfile - name: Run lint - run: yarn run lint + run: yarn run lint --quiet + + - name: Check for circular dependencies + run: yarn run check-circ-deps - name: Run build run: yarn run build:prod @@ -79,11 +87,11 @@ jobs: run: yarn run test:headless # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # Upload coverage reports to Codecov (for Node v12 only) + # Upload coverage reports to Codecov (for one version of Node only) # https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io - uses: codecov/codecov-action@v2 - if: matrix.node-version == '12.x' + uses: codecov/codecov-action@v3 + if: matrix.node-version == '16.x' # Using docker-compose start backend using CI configuration # and load assetstore from a cached copy @@ -97,7 +105,7 @@ jobs: # https://github.com/cypress-io/github-action # (NOTE: to run these e2e tests locally, just use 'ng e2e') - name: Run e2e tests (integration tests) - uses: cypress-io/github-action@v2 + uses: cypress-io/github-action@v4 with: # Run tests in Chrome, headless mode browser: chrome @@ -106,14 +114,14 @@ jobs: start: yarn run serve:ssr # Wait for backend & frontend to be available # NOTE: We use the 'sites' REST endpoint to also ensure the database is ready - wait-on: http://localhost:8080/server/api/core/sites, http://localhost:4000 + wait-on: http://127.0.0.1:8080/server/api/core/sites, http://127.0.0.1:4000 # Wait for 2 mins max for everything to respond wait-on-timeout: 120 # 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@v2 + uses: actions/upload-artifact@v3 if: always() with: name: e2e-test-videos @@ -122,18 +130,26 @@ jobs: # 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@v2 + uses: actions/upload-artifact@v3 if: failure() with: name: e2e-test-screenshots path: cypress/screenshots + - name: Stop app (in case it stays up after e2e tests) + run: | + app_pid=$(lsof -t -i:4000) + if [[ ! -z $app_pid ]]; then + echo "App was still up! (PID: $app_pid)" + kill -9 $app_pid + fi + # Start up the app with SSR enabled (run in background) - name: Start app in SSR (server-side rendering) mode run: | nohup yarn run serve:ssr & printf 'Waiting for app to start' - until curl --output /dev/null --silent --head --fail http://localhost:4000/home; do + until curl --output /dev/null --silent --head --fail http://127.0.0.1:4000/home; do printf '.' sleep 2 done @@ -144,7 +160,7 @@ jobs: # This step also prints entire HTML of homepage for easier debugging if grep fails. - name: Verify SSR (server-side rendering) run: | - result=$(wget -O- -q http://localhost:4000/home) + result=$(wget -O- -q http://127.0.0.1:4000/home) echo "$result" echo "$result" | grep -oE "]*>" | grep DSpace diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml new file mode 100644 index 0000000000..35a2e2d24a --- /dev/null +++ b/.github/workflows/codescan.yml @@ -0,0 +1,49 @@ +# DSpace CodeQL code scanning configuration for GitHub +# https://docs.github.com/en/code-security/code-scanning +# +# NOTE: Code scanning must be run separate from our default build.yml +# because CodeQL requires a fresh build with all tests *disabled*. +name: "Code Scanning" + +# Run this code scan for all pushes / PRs to main branch. Also run once a week. +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + # Don't run if PR is only updating static documentation + paths-ignore: + - '**/*.md' + - '**/*.txt' + schedule: + - cron: "37 0 * * 1" + +jobs: + analyze: + name: Analyze Code + runs-on: ubuntu-latest + # Limit permissions of this GitHub action. Can only write to security-events + permissions: + actions: read + contents: read + security-events: write + + steps: + # https://github.com/actions/checkout + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + # https://github.com/github/codeql-action + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: javascript + + # Autobuild attempts to build any compiled languages + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # Perform GitHub Code Scanning. + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 00ec2fa8f7..908c5c34fd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,6 +12,9 @@ on: - 'dspace-**' pull_request: +permissions: + contents: read # to fetch code (actions/checkout) + jobs: docker: # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' @@ -31,21 +34,29 @@ jobs: # We turn off 'latest' tag by default. TAGS_FLAVOR: | latest=false + # Architectures / Platforms for which we will build Docker images + # If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work. + # If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64. + PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }} steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v2 + uses: actions/checkout@v3 # https://github.com/docker/setup-buildx-action - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 + + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU emulation to build for multiple architectures + uses: docker/setup-qemu-action@v2 # https://github.com/docker/login-action - name: Login to DockerHub # Only login if not a PR, as PRs only trigger a Docker build and not a push if: github.event_name != 'pull_request' - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} @@ -57,7 +68,7 @@ jobs: # Get Metadata for docker_build step below - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image id: meta_build - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v4 with: images: dspace/dspace-angular tags: ${{ env.IMAGE_TAGS }} @@ -66,10 +77,11 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push 'dspace-angular' image id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile + platforms: ${{ env.PLATFORMS }} # For pull requests, we run the Docker build (to ensure no PR changes break the build), # but we ONLY do an image push to DockerHub if it's NOT a PR push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml index 6b9a273ab6..5d7c1c30f7 100644 --- a/.github/workflows/issue_opened.yml +++ b/.github/workflows/issue_opened.yml @@ -5,25 +5,22 @@ on: issues: types: [opened] +permissions: {} jobs: automation: runs-on: ubuntu-latest steps: # Add the new issue to a project board, if it needs triage - # See https://github.com/marketplace/actions/create-project-card-action - - name: Add issue to project board + # See https://github.com/actions/add-to-project + - name: Add issue to triage board # 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: technote-space/create-project-card-action@v1 + uses: actions/add-to-project@v0.3.0 # Note, the authentication token below is an ORG level Secret. - # It must be created/recreated manually via a personal access token with "public_repo" and "admin:org" permissions + # 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 # This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific) with: - GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }} - PROJECT: DSpace Backlog - COLUMN: Triage - CHECK_ORG_PROJECT: true - # Ignore errors - continue-on-error: true + github-token: ${{ secrets.TRIAGE_PROJECT_TOKEN }} + project-url: https://github.com/orgs/DSpace/projects/24 diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml index dcbab18f1b..a840a4fd17 100644 --- a/.github/workflows/label_merge_conflicts.yml +++ b/.github/workflows/label_merge_conflicts.yml @@ -5,21 +5,32 @@ name: Check for merge conflicts # NOTE: This means merge conflicts are only checked for when a PR is merged to main. on: push: - branches: - - main + branches: [ main ] + # So that the `conflict_label_name` is removed if conflicts are resolved, + # we allow this to run for `pull_request_target` so that github secrets are available. + pull_request_target: + types: [ synchronize ] + +permissions: {} jobs: triage: + # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' + if: github.repository == 'dspace/dspace-angular' runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - # See: https://github.com/mschilde/auto-label-merge-conflicts/ + # See: https://github.com/prince-chrismc/label-merge-conflicts-action - name: Auto-label PRs with merge conflicts - uses: mschilde/auto-label-merge-conflicts@v2.0 + uses: prince-chrismc/label-merge-conflicts-action@v2 # Add "merge conflict" label if a merge conflict is detected. Remove it when resolved. # Note, the authentication token is created automatically # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token with: - CONFLICT_LABEL_NAME: 'merge conflict' - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Ignore errors - continue-on-error: true + conflict_label_name: 'merge conflict' + github_token: ${{ secrets.GITHUB_TOKEN }} + conflict_comment: | + Hi @${author}, + Conflicts have been detected against the base branch. + Please [resolve these conflicts](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/about-merge-conflicts) as soon as you can. Thanks! \ No newline at end of file diff --git a/.gitignore b/.gitignore index 026110f222..bdd0d4e589 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.angular/cache /__build__ /__server_build__ /node_modules @@ -36,3 +37,5 @@ package-lock.json .env /nbproject/ + +junit.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..4e732302f4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# How to Contribute + +DSpace is a community built and supported project. We do not have a centralized development or support team, but have a dedicated group of volunteers who help us improve the software, documentation, resources, etc. + +* [Contribute new code via a Pull Request](#contribute-new-code-via-a-pull-request) +* [Contribute documentation](#contribute-documentation) +* [Help others on mailing lists or Slack](#help-others-on-mailing-lists-or-slack) +* [Join a working or interest group](#join-a-working-or-interest-group) + +## Contribute new code via a Pull Request + +We accept [GitHub Pull Requests (PRs)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) at any time from anyone. +Contributors to each release are recognized in our [Release Notes](https://wiki.lyrasis.org/display/DSDOC7x/Release+Notes). + +Code Contribution Checklist +- [ ] PRs _should_ be smaller in size (ideally less than 1,000 lines of code, not including comments & tests) +- [ ] PRs **must** pass [ESLint](https://eslint.org/) validation using `yarn lint` +- [ ] PRs **must** not introduce circular dependencies (verified via `yarn check-circ-deps`) +- [ ] PRs **must** include [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. Large or complex private methods should also have TypeDoc. +- [ ] PRs **must** pass all automated pecs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide). +- [ ] If a PR includes new libraries/dependencies (in `package.json`), then their software licenses **must** align with the [DSpace BSD License](https://github.com/DSpace/dspace-angular/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. +- [ ] Basic technical documentation _should_ be provided for any new features or configuration, either in the PR itself or in the DSpace Wiki documentation. +- [ ] If a PR fixes an issue ticket, please [link them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). + +Additional details on the code contribution process can be found in our [Code Contribution Guidelines](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines) + +## Contribute documentation + +DSpace Documentation is a collaborative effort in a shared Wiki. The latest documentation is at https://wiki.lyrasis.org/display/DSDOC7x + +If you find areas of the DSpace Documentation which you wish to improve, please request a Wiki account by emailing wikihelp@lyrasis.org. +Once you have an account setup, contact @tdonohue (via [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) or email) for access to edit our Documentation. + +## Help others on mailing lists or Slack + +DSpace has our own [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) community and [Mailing Lists](https://wiki.lyrasis.org/display/DSPACE/Mailing+Lists) where discussions take place and questions are answered. +Anyone is welcome to join and help others. We just ask you to follow our [Code of Conduct](https://www.lyrasis.org/about/Pages/Code-of-Conduct.aspx) (adopted via LYRASIS). + +## Join a working or interest group + +Most of the work in building/improving DSpace comes via [Working Groups](https://wiki.lyrasis.org/display/DSPACE/DSpace+Working+Groups) or [Interest Groups](https://wiki.lyrasis.org/display/DSPACE/DSpace+Interest+Groups). + +All working/interest groups are open to anyone to join and participate. A few key groups to be aware of include: + +* [DSpace 7 Working Group](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+Working+Group) - This is the main (mostly volunteer) development team. We meet weekly to review our current development [project board](https://github.com/orgs/DSpace/projects), assigning tickets and/or PRs. +* [DSpace Community Advisory Team (DCAT)](https://wiki.lyrasis.org/display/cmtygp/DSpace+Community+Advisory+Team) - This is an interest group for repository managers/administrators. We meet monthly to discuss DSpace, share tips & provide feedback back to developers. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2d98971112..61d960e7d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,21 @@ # This image will be published as dspace/dspace-angular # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details -FROM node:14-alpine +FROM node:18-alpine WORKDIR /app ADD . /app/ EXPOSE 4000 +# Ensure Python and other build tools are available +# These are needed to install some node modules, especially on linux/arm64 +RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* + # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com # See, for example https://github.com/yarnpkg/yarn/issues/5540 RUN yarn install --network-timeout 300000 -CMD yarn run start:dev + +# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc). +# Listen / accept connections from all IP addresses. +# NOTE: At this time it is only possible to run Docker container in Production mode +# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485 +CMD yarn serve --host 0.0.0.0 diff --git a/README.md b/README.md index 74010f3c5c..c90dc1d08f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v12.x`, `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v12.x`, `v14.x` or `v16.x` and yarn == `v1.x` +- Ensure you're running node `v16.x` or `v18.x` and yarn == `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. @@ -101,7 +101,7 @@ Installing ### Configuring -Default configuration file is located in `config/` folder. +Default runtime configuration file is located in `config/` folder. These configurations can be changed without rebuilding the distribution. To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point. @@ -167,6 +167,22 @@ These configuration sources are collected **at run time**, and written to `dist/ The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`. +#### Buildtime Configuring + +Buildtime configuration must defined before build in order to include in transpiled JavaScript. This is primarily for the server. These settings can be found under `src/environment/` folder. + +To override the default configuration values for development, create local file that override the build time parameters you need to change. + +- Create a new `environment.(dev or development).ts` file in `src/environment/` for a `development` environment; + +If needing to update default configurations values for production, update local file that override the build time parameters you need to change. + +- Update `environment.production.ts` file in `src/environment/` for a `production` environment; + +The environment object is provided for use as import in code and is extended with the runtime configuration on bootstrap of the application. + +> Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap. + #### Using environment variables in code To use environment variables in a UI component, use: @@ -183,7 +199,6 @@ or import { environment } from '../environment.ts'; ``` - Running the app --------------- @@ -193,7 +208,7 @@ After you have installed all dependencies you can now run the app. Run `yarn run When building for production we're using Ahead of Time (AoT) compilation. With AoT, the browser downloads a pre-compiled version of the application, so it can render the application immediately, without waiting to compile the app first. The compiler is roughly half the size of Angular itself, so omitting it dramatically reduces the application payload. -To build the app for production and start the server run: +To build the app for production and start the server (in one command) run: ```bash yarn start @@ -207,6 +222,10 @@ yarn run build:prod ``` This will build the application and put the result in the `dist` folder. You can copy this folder to wherever you need it for your application server. If you will be using the built-in Express server, you'll also need a copy of the `node_modules` folder tucked inside your copy of `dist`. +After building the app for production, it can be started by running: +```bash +yarn run serve:ssr +``` ### Running the application with Docker NOTE: At this time, we do not have production-ready Docker images for DSpace. @@ -268,11 +287,29 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con The test files can be found in the `./cypress/integration/` folder. -Before you can run e2e tests, two things are required: -1. You MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `config.prod.yml` or `config.yml`. You may override this using env variables, see [Configuring](#configuring). -2. Your backend MUST include our Entities Test Data set. Some tests run against a (currently hardcoded) Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data +Before you can run e2e tests, two things are REQUIRED: +1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time. + * After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend. + * If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example: + ``` + DSPACE_REST_SSL = false + DSPACE_REST_HOST = localhost + DSPACE_REST_PORT = 8080 + ``` +2. Your backend MUST include our [Entities Test Data set](https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data). Some tests run against a specific Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. + * (Recommended) The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data + * Alternatively, the Entities Test Data set may be installed via a simple SQL import (e. g. `psql -U dspace < dspace7-entities-data.sql`). See instructions in link above. -Run `ng e2e` to kick off the tests. This will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results. +After performing the above setup, you can run the e2e tests using +``` +ng e2e +```` +NOTE: By default these tests will run against the REST API backend configured via environment variables or in `config.prod.yml`. If you'd rather it use `config.dev.yml`, just set the NODE_ENV environment variable like this: +``` +NODE_ENV=development ng e2e +``` + +The `ng e2e` command will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results. #### Writing E2E Tests @@ -293,8 +330,11 @@ All E2E tests must be created under the `./cypress/integration/` folder, and mus * In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_. * From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page. * Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector + * It's generally best not to rely on attributes like `class` and `id` in tests, as those are likely to change later on. Instead, you can add a `data-test` attribute to makes it clear that it's required for a test. * Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc. - * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. + * When running with server-side rendering enabled, the client first receives HTML without the JS; only once the page is rendered client-side do some elements (e.g. a button that toggles a Bootstrap dropdown) become fully interactive. This can trip up Cypress in some cases as it may try to `click` or `type` in an element that's not fully loaded yet, causing tests to fail. + * To work around this issue, define the attributes you use for Cypress selectors as `[attr.data-test]="'button' | ngBrowserOnly"`. This will only show the attribute in CSR HTML, forcing Cypress to wait until CSR is complete before interacting with the element. + * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. * Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly. * Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests. @@ -311,7 +351,7 @@ Documentation Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/ -Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase. +Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of this codebase. ### Building code documentation @@ -339,10 +379,10 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've - [Sublime Text](http://www.sublimetext.com/3) - [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation) -Collaborating +Contributing ------------- -See [the guide on the wiki](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+-+Angular+UI+Development#DSpace7-AngularUIDevelopment-Howtocontribute) +See [Contributing documentation](CONTRIBUTING.md) File Structure -------------- diff --git a/angular.json b/angular.json index a0a4cd8ea1..b32670ad77 100644 --- a/angular.json +++ b/angular.json @@ -17,7 +17,6 @@ "build": { "builder": "@angular-builders/custom-webpack:browser", "options": { - "extractCss": true, "preserveSymlinks": true, "customWebpackConfig": { "path": "./webpack/webpack.browser.ts", @@ -26,12 +25,10 @@ } }, "allowedCommonJsDependencies": [ - "angular2-text-mask", "cerialize", "core-js", "lodash", "jwt-decode", - "url-parse", "uuid", "webfontloader", "zone.js" @@ -64,19 +61,31 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + }, "production": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.production.ts" + }, + { + "replace": "src/config/store/devtools.ts", + "with": "src/config/store/devtools.prod.ts" } ], "optimization": true, "outputHashing": "all", - "extractCss": true, "namedChunks": false, "aot": true, "extractLicenses": true, @@ -104,6 +113,9 @@ "port": 4000 }, "configurations": { + "development": { + "browserTarget": "dspace-angular:build:development" + }, "production": { "browserTarget": "dspace-angular:build:production" } @@ -157,19 +169,6 @@ } } }, - "lint": { - "builder": "@angular-devkit/build-angular:tslint", - "options": { - "tsConfig": [ - "tsconfig.app.json", - "tsconfig.spec.json", - "cypress/tsconfig.json" - ], - "exclude": [ - "**/node_modules/**" - ] - } - }, "e2e": { "builder": "@cypress/schematic:cypress", "options": { @@ -197,6 +196,10 @@ "tsConfig": "tsconfig.server.json" }, "configurations": { + "development": { + "sourceMap": true, + "optimization": false + }, "production": { "sourceMap": false, "optimization": true, @@ -204,6 +207,10 @@ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.production.ts" + }, + { + "replace": "src/config/store/devtools.ts", + "with": "src/config/store/devtools.prod.ts" } ] } @@ -253,12 +260,22 @@ "watch": true, "headless": false } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } } } } }, "defaultProject": "dspace-angular", "cli": { - "analytics": false + "analytics": false, + "defaultCollection": "@angular-eslint/schematics" } -} \ No newline at end of file +} diff --git a/config/config.example.yml b/config/config.example.yml index ecb2a3cfb9..9abf167b90 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -2,7 +2,8 @@ debug: false # Angular Universal server settings -# NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg. +# NOTE: these settings define where Node.js will start your UI application. Therefore, these +# "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar) ui: ssl: false host: localhost @@ -13,9 +14,12 @@ ui: rateLimiter: windowMs: 60000 # 1 minute max: 500 # limit each IP to 500 requests per windowMs + # Trust X-FORWARDED-* headers from proxies (default = true) + useProxies: true # The REST API server settings -# NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. +# 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. rest: ssl: true host: api7.dspace.org @@ -51,6 +55,8 @@ auth: # Form settings form: + # Sets the spellcheck textarea attribute value + spellCheck: true # NOTE: Map server-side validators to comparative Angular form validators validatorMap: required: required @@ -139,6 +145,9 @@ languages: - code: nl label: Nederlands active: true + - code: pl + label: Polski + active: true - code: pt-PT label: Português active: true @@ -148,6 +157,28 @@ languages: - code: fi label: Suomi active: true + - code: sv + label: Svenska + active: true + - code: tr + label: Türkçe + active: true + - code: kk + label: Қазақ + active: true + - code: bn + label: বাংলা + active: true + - code: hi + label: हिंदी + active: true + - code: el + label: Ελληνικά + active: true + - code: uk + label: Yкраї́нська + active: true + # Browse-By Pages browseBy: @@ -157,11 +188,39 @@ browseBy: fiveYearLimit: 30 # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) defaultLowerLimit: 1900 + # If true, thumbnail images for items will be added to BOTH search and browse result lists. + showThumbnails: true + # The number of entries in a paginated browse results list. + # Rounded to the nearest size in the list of selectable sizes on the + # settings menu. + pageSize: 20 -# Item Page Config +communityList: + # No. of communities to list per expansion (show more) + pageSize: 20 + +homePage: + recentSubmissions: + # The number of item showing in recent submission components + pageSize: 5 + # Sort record of recent submission + sortField: 'dc.date.accessioned' + topLevelCommunityList: + # 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 + +# Item Config item: edit: undoTimeout: 10000 # 10 seconds + # Show the item access status label in items lists + showAccessStatuses: false + bitstream: + # Number of entries in the bitstream list in the item view page. + # Rounded to the nearest size in the list of selectable sizes on the + # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. + pageSize: 5 # Collection Page Config collection: @@ -228,9 +287,26 @@ themes: rel: manifest href: assets/dspace/images/favicons/manifest.webmanifest +# The default bundles that should always be displayed as suggestions when you upload a new bundle +bundle: + standardBundles: [ ORIGINAL, THUMBNAIL, LICENSE ] + # Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video'). # For images, this enables a gallery viewer where you can zoom or page through images. # For videos, this enables embedded video streaming mediaViewer: image: false video: false + +# 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. +info: + enableEndUserAgreement: true + enablePrivacyStatement: 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. +markdown: + enabled: false + mathjax: false diff --git a/cypress.json b/cypress.json index e06de8e4c5..3adf7839c2 100644 --- a/cypress.json +++ b/cypress.json @@ -5,6 +5,21 @@ "screenshotsFolder": "cypress/screenshots", "pluginsFile": "cypress/plugins/index.ts", "fixturesFolder": "cypress/fixtures", - "baseUrl": "http://localhost:4000", - "retries": 2 -} \ No newline at end of file + "baseUrl": "http://127.0.0.1:4000", + "retries": { + "runMode": 2, + "openMode": 0 + }, + "env": { + "DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com", + "DSPACE_TEST_ADMIN_PASSWORD": "dspace", + "DSPACE_TEST_COMMUNITY": "0958c910-2037-42a9-81c7-dca80e3892b4", + "DSPACE_TEST_COLLECTION": "282164f5-d325-4740-8dd1-fa4d6d3e7200", + "DSPACE_TEST_ENTITY_PUBLICATION": "e98b0f27-5c19-49a0-960d-eb6ad5287067", + "DSPACE_TEST_SEARCH_TERM": "test", + "DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection", + "DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144", + "DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com", + "DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace" + } +} diff --git a/cypress/.gitignore b/cypress/.gitignore index 99bd2a6312..645beff45f 100644 --- a/cypress/.gitignore +++ b/cypress/.gitignore @@ -1,2 +1,3 @@ screenshots/ videos/ +downloads/ diff --git a/cypress/integration/homepage.spec.ts b/cypress/integration/homepage.spec.ts index ddde260bc7..8fdf61dbf7 100644 --- a/cypress/integration/homepage.spec.ts +++ b/cypress/integration/homepage.spec.ts @@ -16,8 +16,8 @@ describe('Homepage', () => { it('should have a working search box', () => { const queryString = 'test'; - cy.get('ds-search-form input[name="query"]').type(queryString); - cy.get('ds-search-form button.search-button').click(); + cy.get('[data-test="search-box"]').type(queryString); + cy.get('[data-test="search-button"]').click(); cy.url().should('include', '/search'); cy.url().should('include', 'query=' + encodeURI(queryString)); }); diff --git a/cypress/integration/login-modal.spec.ts b/cypress/integration/login-modal.spec.ts new file mode 100644 index 0000000000..fece28b425 --- /dev/null +++ b/cypress/integration/login-modal.spec.ts @@ -0,0 +1,126 @@ +import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support'; + +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'); + } +}; + +describe('Login Modal', () => { + it('should login when clicking button & stay on same page', () => { + const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; + cy.visit(ENTITYPAGE); + + // 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'); + + page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.get('ds-log-in').should('not.exist'); + + // 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'); + }); + + 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'); + + // Login, and the tag should no longer exist + page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.get('.form-login').should('not.exist'); + + // 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'); + }); + + it('should support logout', () => { + // First authenticate & access homepage + cy.login(TEST_ADMIN_USER, 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'); + + // 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'); + }); + + it('should allow new user registration', () => { + cy.visit('/'); + + page.openLoginMenu(); + + // Registration link should be visible + cy.get('ds-themed-navbar [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'); + }); + + it('should allow forgot password', () => { + cy.visit('/'); + + page.openLoginMenu(); + + // Forgot password link should be visible + cy.get('ds-themed-navbar [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'); + }); +}); diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/integration/my-dspace.spec.ts new file mode 100644 index 0000000000..48f44eecb9 --- /dev/null +++ b/cypress/integration/my-dspace.spec.ts @@ -0,0 +1,155 @@ +import { Options } from 'cypress-axe'; +import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +describe('My DSpace page', () => { + 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(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + cy.get('ds-my-dspace-page').should('exist'); + + // 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 }); + + // Analyze for accessibility issues + testA11y( + { + include: ['ds-my-dspace-page'], + exclude: [ + ['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175 + ], + }, + { + rules: { + // Search filters fail these two "moderate" impact rules + 'heading-order': { enabled: false }, + 'landmark-unique': { enabled: false } + } + } as Options + ); + }); + + 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(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + cy.get('ds-my-dspace-page').should('exist'); + + // Click button in sidebar to display detailed view + cy.get('ds-search-sidebar [data-test="detail-view"]').click(); + + cy.get('ds-object-detail').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-my-dspace-page', + { + rules: { + // Search filters fail these two "moderate" impact rules + 'heading-order': { enabled: false }, + 'landmark-unique': { enabled: false } + } + } as Options + ); + }); + + // 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(TEST_SUBMIT_USER, 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(TEST_SUBMIT_COLLECTION_NAME); + + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').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', 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(TEST_SUBMIT_USER, 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'); + }); + +}); diff --git a/cypress/integration/search-navbar.spec.ts b/cypress/integration/search-navbar.spec.ts index 19a3d56ed4..babd9b9dfd 100644 --- a/cypress/integration/search-navbar.spec.ts +++ b/cypress/integration/search-navbar.spec.ts @@ -1,49 +1,66 @@ +import { TEST_SEARCH_TERM } from 'cypress/support'; + const page = { fillOutQueryInNavBar(query) { // Click the magnifying glass - cy.get('.navbar-container #search-navbar-container form a').click(); + cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); // Fill out a query in input that appears - cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type(query); + cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); }, submitQueryByPressingEnter() { - cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type('{enter}'); + cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); }, submitQueryByPressingIcon() { - cy.get('.navbar-container #search-navbar-container form .submit-icon').click(); + cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); } }; describe('Search from Navigation Bar', () => { // NOTE: these tests currently assume this query will return results! - const query = 'test'; + const query = 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=' + 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('ds-item-search-result-list-element').should('be.visible'); + 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=' + 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('ds-item-search-result-list-element').should('be.visible'); + 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=' + 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('ds-item-search-result-list-element').should('be.visible'); + cy.get('[data-test="list-object"]').should('be.visible'); }); }); diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts index 859c765d2e..623c370c56 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/integration/search-page.spec.ts @@ -1,34 +1,30 @@ import { Options } from 'cypress-axe'; +import { TEST_SEARCH_TERM } from 'cypress/support'; import { testA11y } from 'cypress/support/utils'; describe('Search Page', () => { - // unique ID of the search form (for selecting specific elements below) - const SEARCHFORM_ID = '#search-form'; - - it('should contain query value when navigating to page with query parameter', () => { - const queryString = 'test query'; - cy.visit('/search?query=' + queryString); - cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', 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(SEARCHFORM_ID + ' input[name="query"]').type(queryString); - cy.get(SEARCHFORM_ID + ' button.search-button').click(); + cy.get('[data-test="search-box"]').type(queryString); + cy.get('[data-test="search-button"]').click(); cy.url().should('include', 'query=' + encodeURI(queryString)); }); - it('should pass accessibility tests', () => { - cy.visit('/search'); + it('should load results and pass accessibility tests', () => { + cy.visit('/search?query=' + TEST_SEARCH_TERM); + cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); // tag must be loaded cy.get('ds-search-page').should('exist'); + // 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('.filter-toggle').click({ multiple: true }); + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); // Analyze for accessibility issues testA11y( @@ -48,16 +44,18 @@ describe('Search Page', () => { ); }); - it('should pass accessibility tests in Grid view', () => { - cy.visit('/search'); + it('should have a working grid view that passes accessibility tests', () => { + cy.visit('/search?query=' + TEST_SEARCH_TERM); - // Click to display grid view - // TODO: These buttons should likely have an easier way to uniquely select - cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?view=grid"] > .fas').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('exist'); + // 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', { diff --git a/cypress/integration/submission.spec.ts b/cypress/integration/submission.spec.ts new file mode 100644 index 0000000000..9eef596b02 --- /dev/null +++ b/cypress/integration/submission.spec.ts @@ -0,0 +1,138 @@ +import { Options } from 'cypress-axe'; +import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +describe('New Submission page', () => { + // NOTE: We already test that new 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=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + // 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'); + + // A Collection menu button should exist & it's value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', 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'); + + // 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=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, 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=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, 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'); + // Close the upload success notice + cy.get('[data-dismiss="alert"]').click({multiple: true}); + + // 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'); + }); + +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index c6eb874232..ead38afb92 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -1,15 +1,34 @@ +const fs = require('fs'); + // 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) => { - // 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 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; } }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index af1f44a0fc..04c217aa0f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,43 +1,110 @@ // *********************************************** -// This example namespace declaration will help -// with Intellisense and code completion in your -// IDE or Text Editor. +// This File is for Custom Cypress commands. +// See docs at https://docs.cypress.io/api/cypress-api/custom-commands // *********************************************** -// declare namespace Cypress { -// interface Chainable { -// customCommand(param: any): typeof customCommand; -// } -// } -// -// function customCommand(param: any): void { -// console.warn(param); -// } -// -// NOTE: You can use it like so: -// Cypress.Commands.add('customCommand', customCommand); -// -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) + +import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; +import { FALLBACK_TEST_REST_BASE_URL } from '.'; + +// Declare Cypress namespace to help with Intellisense & code completion in IDEs +// ALL custom commands MUST be listed here for code completion to work +// tslint:disable-next-line:no-namespace +declare global { + namespace Cypress { + interface Chainable { + /** + * Login to backend before accessing the next page. Ensures that the next + * call to "cy.visit()" will be authenticated as this user. + * @param email email to login as + * @param password password to login as + */ + login(email: string, password: string): typeof login; + + /** + * Login via form before accessing the next page. Useful to fill out login + * form when a cy.visit() call is to an a page which requires authentication. + * @param email email to login as + * @param password password to login as + */ + loginViaForm(email: string, password: string): typeof loginViaForm; + } + } +} + +/** + * Login user via REST API directly, and pass authentication token to UI via + * the UI's dsAuthInfo cookie. + * WARNING: WHILE THIS METHOD WORKS, OCCASIONALLY RANDOM AUTHENTICATION ERRORS OCCUR. + * At this time "loginViaForm()" seems more consistent/stable. + * @param email email to login as + * @param password password to login as + */ +function login(email: string, password: string): void { + // 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. + cy.task('readUIConfig').then((str: string) => { + // Parse config into a JSON object + const config = JSON.parse(str); + + // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. + 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); + } else { + console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl); + baseRestUrl = config.rest.baseUrl; + } + + // To login via REST, first we have to do a GET to obtain a valid CSRF token + cy.request( baseRestUrl + '/api/authn/status' ) + .then((response) => { + // We should receive a CSRF token returned in a response header + expect(response.headers).to.have.property('dspace-xsrf-token'); + const csrfToken = response.headers['dspace-xsrf-token']; + + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { 'X-XSRF-TOKEN' : 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); + + // 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); + + +/** + * Login user via displayed login form + * @param email email to login as + * @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(); +} +// Add as a Cypress command (i.e. assign to 'cy.loginViaForm') +Cypress.Commands.add('loginViaForm', loginViaForm); \ No newline at end of file diff --git a/cypress/support/index.ts b/cypress/support/index.ts index e8b10b9cfb..70da23f044 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -13,14 +13,51 @@ // https://on.cypress.io/configuration // *********************************************************** -// When a command from ./commands is ready to use, import with `import './commands'` syntax -// import './commands'; +// 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'; +// 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}'); +}); + +// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. +// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test. +// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/ +afterEach(() => { + cy.window().then((win) => { + win.location.href = 'about:blank'; + }); +}); + + // Global constants used in tests -export const TEST_COLLECTION = '282164f5-d325-4740-8dd1-fa4d6d3e7200'; -export const TEST_COMMUNITY = '0958c910-2037-42a9-81c7-dca80e3892b4'; -export const TEST_ENTITY_PUBLICATION = 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; +// May be overridden in our cypress.json config file using specified environment variables. +// Default values listed here are all valid for the Demo Entities Data set available at +// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data +// (This is the data set used in our CI environment) + +// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL +// from the Angular UI's config.json. See 'getBaseRESTUrl()' in commands.ts +export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; + +// Admin account used for administrative tests +export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com'; +export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace'; +// Community/collection/publication used for view/edit tests +export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200'; +export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4'; +export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; +// Search term (should return results) used in search tests +export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test'; +// Collection used for submission tests +export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection'; +export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144'; +export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com'; +export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace'; diff --git a/docker/README.md b/docker/README.md index a2f4ef3362..1a9fee0a81 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,93 +1,95 @@ -# Docker Compose files - -*** -:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario. -*** - -## 'Dockerfile' in root directory -This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' - -``` -docker build -t dspace/dspace-angular:dspace-7_x . -``` - -This image is built *automatically* after each commit is made to the `main` branch. - -Admins to our DockerHub repo can manually publish with the following command. -``` -docker push dspace/dspace-angular:dspace-7_x -``` - -## 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. -- docker-compose-rest.yml - - Runs a published instance of the DSpace 7 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. -- cli.yml - - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. -- cli.assetstore.yml - - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. - - -## To refresh / pull DSpace images from Dockerhub -``` -docker-compose -f docker/docker-compose.yml pull -``` - -## To build DSpace images using code in your branch -``` -docker-compose -f docker/docker-compose.yml build -``` - -## To start DSpace (REST and Angular) from your branch - -``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d -``` - -## Run DSpace REST and DSpace Angular from local branches. -_The system will be started in 2 steps. Each step shares the same docker network._ - -From DSpace/DSpace (build as needed) -``` -docker-compose -p d7 up -d -``` - -From DSpace/DSpace-angular -``` -docker-compose -p d7 -f docker/docker-compose.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 -``` - -Load content from AIP files -``` -docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli -``` - -## Alternative Ingest - Use Entities dataset -_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 -``` - -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 -``` - -## End to end testing of the rest api (runs in travis). -_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._ - -``` -docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d -``` +# Docker Compose files + +*** +:warning: **THESE IMAGES ARE NOT PRODUCTION READY** The below Docker Compose images/resources were built for development/testing only. Therefore, they may not be fully secured or up-to-date, and should not be used in production. + +If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario. +*** + +## 'Dockerfile' in root directory +This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' + +``` +docker build -t dspace/dspace-angular:dspace-7_x . +``` + +This image is built *automatically* after each commit is made to the `main` branch. + +Admins to our DockerHub repo can manually publish with the following command. +``` +docker push dspace/dspace-angular:dspace-7_x +``` + +## 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. +- docker-compose-rest.yml + - Runs a published instance of the DSpace 7 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. +- cli.yml + - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. +- cli.assetstore.yml + - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. + + +## To refresh / pull DSpace images from Dockerhub +``` +docker-compose -f docker/docker-compose.yml pull +``` + +## To build DSpace images using code in your branch +``` +docker-compose -f docker/docker-compose.yml build +``` + +## To start DSpace (REST and Angular) from your branch + +``` +docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d +``` + +## Run DSpace REST and DSpace Angular from local branches. +_The system will be started in 2 steps. Each step shares the same docker network._ + +From DSpace/DSpace (build as needed) +``` +docker-compose -p d7 up -d +``` + +From DSpace/DSpace-angular +``` +docker-compose -p d7 -f docker/docker-compose.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 +``` + +Load content from AIP files +``` +docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli +``` + +## Alternative Ingest - Use Entities dataset +_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 +``` + +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 +``` + +## End to end testing of the rest api (runs in travis). +_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._ + +``` +docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d +``` diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index c2846286d7..40e4974c7c 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -35,6 +35,6 @@ services: tar xvfz /tmp/assetstore.tar.gz fi - /dspace/bin/dspace index-discovery + /dspace/bin/dspace index-discovery -b /dspace/bin/dspace oai import /dspace/bin/dspace oai clean-cache diff --git a/docker/db.entities.yml b/docker/db.entities.yml index 818d14877c..6473bf2e38 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -20,12 +20,12 @@ services: 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 - - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql + - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql dspace: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### # 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 + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 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 @@ -35,7 +35,7 @@ services: - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored sed -i '/name-map collection-handle="default".*/a \\n \ \ \ diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index a895314a17..ef84c14f43 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -24,8 +24,8 @@ services: # __D__ => "-" (e.g. google__D__metadata => google-metadata) # dspace.dir, dspace.server.url and dspace.ui.url dspace__P__dir: /dspace - dspace__P__server__P__url: http://localhost:8080/server - dspace__P__ui__P__url: http://localhost:4000 + dspace__P__server__P__url: http://127.0.0.1:8080/server + dspace__P__ui__P__url: http://127.0.0.1:4000 # db.url: Ensure we are using the 'dspacedb' image for our database db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' # solr.server: Ensure we are using the 'dspacesolr' image for Solr @@ -46,14 +46,14 @@ services: - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. Finally, start Tomcat entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored catalina.sh run # DSpace database container # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data @@ -63,7 +63,7 @@ services: # 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-2021-04-14.sql + 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 networks: diff --git a/karma.conf.js b/karma.conf.js index 24cd067fd1..8418312b1a 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -22,7 +22,7 @@ module.exports = function (config) { reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, - reporters: ['mocha', 'kjhtml'], + reporters: ['mocha', 'kjhtml', 'coverage-istanbul'], mochaReporter: { ignoreSkipped: true, output: 'autowatch' diff --git a/package.json b/package.json index 278afdf6c3..52b089be37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "0.0.0", + "version": "7.5.0-next", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -9,10 +9,11 @@ "start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"", "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod", + "preserve": "yarn base-href", "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "serve:ssr": "node dist/server/main", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", - "build": "ng build", + "build": "ng build --configuration development", "build:stats": "ng build --stats-json", "build:prod": "yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", @@ -36,7 +37,9 @@ "merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", "cypress:open": "cypress open", "cypress:run": "cypress run", - "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts" + "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 ./" }, "browser": { "fs": false, @@ -47,146 +50,150 @@ "private": true, "resolutions": { "minimist": "^1.2.5", - "webdriver-manager": "^12.1.8" + "webdriver-manager": "^12.1.8", + "ts-node": "10.2.1" }, "dependencies": { - "@angular/animations": "~11.2.14", - "@angular/cdk": "^11.2.13", - "@angular/common": "~11.2.14", - "@angular/compiler": "~11.2.14", - "@angular/core": "~11.2.14", - "@angular/forms": "~11.2.14", - "@angular/localize": "11.2.14", - "@angular/platform-browser": "~11.2.14", - "@angular/platform-browser-dynamic": "~11.2.14", - "@angular/platform-server": "~11.2.14", - "@angular/router": "~11.2.14", - "@kolkov/ngx-gallery": "^1.2.3", - "@ng-bootstrap/ng-bootstrap": "9.1.3", - "@ng-dynamic-forms/core": "^13.0.0", - "@ng-dynamic-forms/ui-ng-bootstrap": "^13.0.0", - "@ngrx/effects": "^11.1.1", - "@ngrx/router-store": "^11.1.1", - "@ngrx/store": "^11.1.1", - "@nguniversal/express-engine": "11.2.1", + "@angular/animations": "~13.3.12", + "@angular/cdk": "^13.2.6", + "@angular/common": "~13.3.12", + "@angular/compiler": "~13.3.12", + "@angular/core": "~13.3.12", + "@angular/forms": "~13.3.12", + "@angular/localize": "13.3.12", + "@angular/platform-browser": "~13.3.12", + "@angular/platform-browser-dynamic": "~13.3.12", + "@angular/platform-server": "~13.3.12", + "@angular/router": "~13.3.12", + "@babel/runtime": "7.17.2", + "@kolkov/ngx-gallery": "^2.0.1", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@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": "^13.0.2", + "@ngrx/router-store": "^13.0.2", + "@ngrx/store": "^13.0.2", + "@nguniversal/express-engine": "^13.0.2", "@ngx-translate/core": "^13.0.0", - "@nicky-lenaers/ngx-scroll-to": "^9.0.0", + "@nicky-lenaers/ngx-scroll-to": "^13.0.0", + "@types/grecaptcha": "^3.0.4", "angular-idle-preload": "3.0.0", - "angular2-text-mask": "9.0.0", - "angulartics2": "^10.0.0", - "bootstrap": "4.3.1", - "caniuse-lite": "^1.0.30001165", + "angulartics2": "^12.0.0", + "axios": "^0.27.2", + "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.8.0", + "colors": "^1.4.0", "compression": "^1.7.4", "cookie-parser": "1.4.5", "core-js": "^3.7.0", + "date-fns": "^2.29.3", + "date-fns-tz": "^1.3.7", "deepmerge": "^4.2.2", "express": "^4.17.1", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.0.0-1", - "file-saver": "^2.0.5", "filesize": "^6.1.0", - "font-awesome": "4.7.0", "http-proxy-middleware": "^1.0.5", - "https": "1.0.0", "js-cookie": "2.2.1", "js-yaml": "^4.1.0", "json5": "^2.1.3", "jsonschema": "1.4.0", "jwt-decode": "^3.1.2", - "klaro": "^0.7.10", + "klaro": "^0.7.18", "lodash": "^4.17.21", + "markdown-it": "^13.0.1", + "markdown-it-mathjax3": "^4.3.1", "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", - "moment": "^2.29.1", "morgan": "^1.10.0", - "ng-mocks": "11.11.2", + "ng-mocks": "^13.1.1", "ng2-file-upload": "1.4.0", "ng2-nouislider": "^1.8.3", "ngx-infinite-scroll": "^10.0.1", - "ngx-moment": "^5.0.0", "ngx-pagination": "5.0.0", "ngx-sortablejs": "^11.1.0", + "ngx-ui-switch": "^13.0.2", "nouislider": "^14.6.3", "pem": "1.14.4", - "postcss-cli": "^8.3.0", + "prop-types": "^15.7.2", + "react-copy-to-clipboard": "^5.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^6.6.3", + "rxjs": "^7.5.5", + "sanitize-html": "^2.7.2", "sortablejs": "1.13.0", - "tslib": "^2.0.0", - "url-parse": "^1.5.3", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "^0.10.3" + "zone.js": "~0.11.5" }, "devDependencies": { - "@angular-builders/custom-webpack": "10.0.1", - "@angular-devkit/build-angular": "~0.1102.15", - "@angular/cli": "~11.2.15", - "@angular/compiler-cli": "~11.2.14", - "@angular/language-service": "~11.2.14", + "@angular-builders/custom-webpack": "~13.1.0", + "@angular-devkit/build-angular": "~13.3.10", + "@angular-eslint/builder": "13.1.0", + "@angular-eslint/eslint-plugin": "13.1.0", + "@angular-eslint/eslint-plugin-template": "13.1.0", + "@angular-eslint/schematics": "13.1.0", + "@angular-eslint/template-parser": "13.1.0", + "@angular/cli": "~13.3.10", + "@angular/compiler-cli": "~13.3.12", + "@angular/language-service": "~13.3.12", "@cypress/schematic": "^1.5.0", - "@fortawesome/fontawesome-free": "^5.5.0", - "@ngrx/store-devtools": "^11.1.1", - "@ngtools/webpack": "10.2.3", - "@nguniversal/builders": "~11.2.1", + "@fortawesome/fontawesome-free": "^6.2.1", + "@ngrx/store-devtools": "^13.0.2", + "@ngtools/webpack": "^13.2.6", + "@nguniversal/builders": "^13.1.1", "@types/deep-freeze": "0.1.2", "@types/express": "^4.17.9", - "@types/file-saver": "^2.0.1", "@types/jasmine": "~3.6.0", - "@types/jasminewd2": "~2.0.8", "@types/js-cookie": "2.2.6", "@types/lodash": "^4.14.165", "@types/node": "^14.14.9", - "axe-core": "^4.3.3", - "codelyzer": "^6.0.0", - "compression-webpack-plugin": "^3.0.1", + "@types/sanitize-html": "^2.6.2", + "@typescript-eslint/eslint-plugin": "5.11.0", + "@typescript-eslint/parser": "5.11.0", + "axe-core": "^4.4.3", + "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "css-loader": "3.4.0", - "cssnano": "^4.1.10", - "cypress": "8.6.0", - "cypress-axe": "^0.13.0", - "debug-loader": "^0.0.1", + "cypress": "9.7.0", + "cypress-axe": "^0.14.0", "deep-freeze": "0.0.1", - "dotenv": "^8.2.0", - "fork-ts-checker-webpack-plugin": "^6.0.3", - "html-loader": "^1.3.2", - "html-webpack-plugin": "^4.5.0", - "jasmine-core": "~3.6.0", - "jasmine-marbles": "0.6.0", - "jasmine-spec-reporter": "~5.0.0", - "karma": "^5.2.3", + "eslint": "^8.2.0", + "eslint-plugin-deprecation": "^1.3.2", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jsdoc": "^39.6.4", + "eslint-plugin-lodash": "^7.4.0", + "eslint-plugin-unused-imports": "^2.0.0", + "express-static-gzip": "^2.1.5", + "jasmine-core": "^3.8.0", + "jasmine-marbles": "0.9.2", + "karma": "^6.3.14", "karma-chrome-launcher": "~3.1.0", "karma-coverage-istanbul-reporter": "~3.0.2", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "nodemon": "^2.0.15", - "optimize-css-assets-webpack-plugin": "^5.0.4", - "postcss-apply": "0.11.0", - "postcss-import": "^12.0.1", - "postcss-loader": "^3.0.0", - "postcss-preset-env": "6.7.0", + "ngx-mask": "^13.1.7", + "nodemon": "^2.0.20", + "postcss": "^8.1", + "postcss-apply": "0.12.0", + "postcss-import": "^14.0.0", + "postcss-loader": "^4.0.3", + "postcss-preset-env": "^7.4.2", "postcss-responsive-type": "1.0.0", - "protractor": "^7.0.0", - "protractor-istanbul-plugin": "2.0.0", - "raw-loader": "0.5.1", "react": "^16.14.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "rxjs-spy": "^7.5.3", + "rxjs-spy": "^8.0.2", + "sass": "~1.33.0", + "sass-loader": "^12.6.0", "sass-resources-loader": "^2.1.1", - "script-ext-html-webpack-plugin": "2.1.5", - "string-replace-loader": "^2.3.0", - "terser-webpack-plugin": "^2.3.1", - "ts-loader": "^5.2.0", "ts-node": "^8.10.2", - "tslint": "^6.1.3", - "typescript": "~4.0.5", - "webpack": "^4.44.2", + "typescript": "~4.5.5", + "webpack": "^5.69.1", "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.2.0", "webpack-dev-server": "^4.5.0" diff --git a/scripts/base-href.ts b/scripts/base-href.ts new file mode 100644 index 0000000000..7212e1c516 --- /dev/null +++ b/scripts/base-href.ts @@ -0,0 +1,36 @@ +import { existsSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { AppConfig } from '../src/config/app-config.interface'; +import { buildAppConfig } from '../src/config/config.server'; + +/** + * Script to set baseHref as `ui.nameSpace` for development mode. Adds `baseHref` to angular.json build options. + * + * Usage (see package.json): + * + * yarn base-href + */ + +const appConfig: AppConfig = buildAppConfig(); + +const angularJsonPath = join(process.cwd(), 'angular.json'); + +if (!existsSync(angularJsonPath)) { + console.error(`Error:\n${angularJsonPath} does not exist\n`); + process.exit(1); +} + +try { + const angularJson = require(angularJsonPath); + + const baseHref = `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`; + + console.log(`Setting baseHref to ${baseHref} in angular.json`); + + angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref; + + writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n'); +} catch (e) { + console.error(e); +} diff --git a/scripts/env-to-yaml.ts b/scripts/env-to-yaml.ts index edcdfd90b4..6e8153f4c1 100644 --- a/scripts/env-to-yaml.ts +++ b/scripts/env-to-yaml.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs'; -import * as yaml from 'js-yaml'; +import { existsSync, writeFileSync } from 'fs'; +import { dump } from 'js-yaml'; import { join } from 'path'; /** @@ -18,18 +18,18 @@ if (args[0] === undefined) { const envFullPath = join(process.cwd(), args[0]); -if (!fs.existsSync(envFullPath)) { +if (!existsSync(envFullPath)) { console.error(`Error:\n${envFullPath} does not exist\n`); process.exit(1); } try { - const env = require(envFullPath); + const env = require(envFullPath).environment; - const config = yaml.dump(env); + const config = dump(env); if (args[1]) { const ymlFullPath = join(process.cwd(), args[1]); - fs.writeFileSync(ymlFullPath, config); + writeFileSync(ymlFullPath, config); } else { console.log(config); } diff --git a/scripts/serve.ts b/scripts/serve.ts index bf5506b8bd..ee8570a45c 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -1,4 +1,4 @@ -import * as child from 'child_process'; +import { spawn } from 'child_process'; import { AppConfig } from '../src/config/app-config.interface'; import { buildAppConfig } from '../src/config/config.server'; @@ -7,8 +7,9 @@ const appConfig: AppConfig = buildAppConfig(); /** * Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl + * Any CLI arguments given to this script are patched through to `ng serve` as well. */ -child.spawn( - `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`, +spawn( + `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl} ${process.argv.slice(2).join(' ')} --configuration development`, { stdio: 'inherit', shell: true } ); diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts old mode 100755 new mode 100644 index ad8a712f21..96ba0d4010 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -1,4 +1,5 @@ -import { projectRoot} from '../webpack/helpers'; +import { projectRoot } from '../webpack/helpers'; + const commander = require('commander'); const fs = require('fs'); const JSON5 = require('json5'); @@ -119,7 +120,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { outputChunks.forEach(function (chunk) { progressBar.increment(); chunk.split("\n").forEach(function (line) { - file.write(" " + line + "\n"); + file.write((line === '' ? '' : ` ${line}`) + "\n"); }); }); file.write("\n}"); @@ -192,7 +193,10 @@ function createNewChunkComparingSourceAndTarget(correspondingTargetChunk, source const targetList = correspondingTargetChunk.split("\n"); const oldKeyValueInTargetComments = getSubStringWithRegex(correspondingTargetChunk, "\\s*\\/\\/\\s*\".*"); - const keyValueTarget = targetList[targetList.length - 1]; + let keyValueTarget = targetList[targetList.length - 1]; + if (!keyValueTarget.endsWith(",")) { + keyValueTarget = keyValueTarget + ","; + } if (oldKeyValueInTargetComments != null) { const oldKeyValueUncommented = getSubStringWithRegex(oldKeyValueInTargetComments[0], "\".*")[0]; diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts index aa3b64f62b..9066777c42 100644 --- a/scripts/test-rest.ts +++ b/scripts/test-rest.ts @@ -1,9 +1,9 @@ -import * as http from 'http'; -import * as https from 'https'; +import { request } from 'http'; +import { request as https_request } from 'https'; import { AppConfig } from '../src/config/app-config.interface'; import { buildAppConfig } from '../src/config/config.server'; - + const appConfig: AppConfig = buildAppConfig(); /** @@ -20,9 +20,15 @@ console.log(`...Testing connection to REST API at ${restUrl}...\n`); // If SSL enabled, test via HTTPS, else via HTTP if (appConfig.rest.ssl) { - const req = https.request(restUrl, (res) => { + const req = https_request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); - res.on('data', (data) => { + // We will keep reading data until the 'end' event fires. + // This ensures we don't just read the first chunk. + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { checkJSONResponse(data); }); }); @@ -33,9 +39,15 @@ if (appConfig.rest.ssl) { req.end(); } else { - const req = http.request(restUrl, (res) => { + const req = request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); - res.on('data', (data) => { + // We will keep reading data until the 'end' event fires. + // This ensures we don't just read the first chunk. + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { checkJSONResponse(data); }); }); diff --git a/server.ts b/server.ts index da3b877bc1..608c214076 100644 --- a/server.ts +++ b/server.ts @@ -15,16 +15,21 @@ * import for `ngExpressEngine`. */ -import 'zone.js/dist/zone-node'; +import 'zone.js/node'; import 'reflect-metadata'; import 'rxjs'; -import * as pem from 'pem'; -import * as https from 'https'; +/* eslint-disable import/no-namespace */ import * as morgan from 'morgan'; import * as express from 'express'; -import * as bodyParser from 'body-parser'; import * as compression from 'compression'; +import * as expressStaticGzip from 'express-static-gzip'; +/* eslint-enable import/no-namespace */ + +import axios from 'axios'; +import { createCertificate } from 'pem'; +import { createServer } from 'https'; +import { json } from 'body-parser'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; @@ -37,15 +42,16 @@ import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasValue, hasNoValue } from './src/app/shared/empty.util'; +import { hasNoValue, hasValue } from './src/app/shared/empty.util'; import { UIServerConfig } from './src/config/ui-server-config.interface'; import { ServerAppModule } from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; -import { AppConfig, APP_CONFIG } 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'; /* * Set path for the browser application's dist folder @@ -66,19 +72,29 @@ extendEnvironmentWithAppConfig(environment, appConfig); // The Express app is exported so that it can be used by serverless Functions. export function app() { + const router = express.Router(); + /* * Create a new express application */ const server = express(); + // Tell Express to trust X-FORWARDED-* headers from proxies + // See https://expressjs.com/en/guide/behind-proxies.html + server.set('trust proxy', environment.ui.useProxies); + /* * If production mode is enabled in the environment file: * - Enable Angular's production mode - * - Enable compression for response bodies. See [compression](https://github.com/expressjs/compression) + * - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression) */ if (environment.production) { enableProdMode(); - server.use(compression()); + server.use(compression({ + // only compress responses we've marked as SSR + // otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin + filter: (_, res) => res.locals.ssr, + })); } /* @@ -97,7 +113,7 @@ export function app() { * Add parser for request bodies * See [morgan](https://github.com/expressjs/body-parser) */ - server.use(bodyParser.json()); + server.use(json()); // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) server.engine('html', (_, options, callback) => @@ -133,7 +149,11 @@ export function app() { /** * Proxy the sitemaps */ - server.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, changeOrigin: true })); + router.use('/sitemap**', createProxyMiddleware({ + target: `${environment.rest.baseUrl}/sitemaps`, + pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), + changeOrigin: true + })); /** * Checks if the rateLimiter property is present @@ -150,15 +170,28 @@ export function app() { /* * Serve static resources (images, i18n messages, …) + * Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip) */ - server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); + router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { + index: false, + enableBrotli: true, + orderPreference: ['br', 'gzip'], + })); + /* * Fallthrough to the IIIF viewer (must be included in the build). */ - server.use('/iiif', express.static(IIIF_VIEWER, {index:false})); + router.use('/iiif', express.static(IIIF_VIEWER, { index: false })); + + /** + * Checking server status + */ + server.get('/app/health', healthCheck); // Register the ngApp callback function to handle incoming requests - server.get('*', ngApp); + router.get('*', ngApp); + + server.use(environment.ui.nameSpace, router); return server; } @@ -180,6 +213,7 @@ function ngApp(req, res) { providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] }, (err, data) => { if (hasNoValue(err) && hasValue(data)) { + res.locals.ssr = true; // mark response as SSR res.send(data); } 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 @@ -191,13 +225,25 @@ function ngApp(req, res) { if (hasValue(err)) { console.warn('Error details : ', err); } - res.sendFile(DIST_FOLDER + '/index.html'); + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); } }); } else { // If preboot is disabled, just serve the client console.log('Universal off, serving for direct CSR'); - res.sendFile(DIST_FOLDER + '/index.html'); + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); } } @@ -223,7 +269,7 @@ function serverStarted() { * @param keys SSL credentials */ function createHttpsServer(keys) { - https.createServer({ + createServer({ key: keys.serviceKey, cert: keys.certificate }, app).listen(environment.ui.port, environment.ui.host, () => { @@ -243,6 +289,8 @@ function run() { } function start() { + logStartupMessage(environment); + /* * If SSL is enabled * - Read credentials from configuration files @@ -275,7 +323,7 @@ function start() { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] - pem.createCertificate({ + createCertificate({ days: 1, selfSigned: true }, (error, keys) => { @@ -287,6 +335,21 @@ function start() { } } +/* + * The callback function to serve health check requests + */ +function healthCheck(req, res) { + const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`; + axios.get(baseUrl) + .then((response) => { + res.status(response.status).send(response.data); + }) + .catch((error) => { + res.status(error.response.status).send({ + error: error.message + }); + }); +} // Webpack will replace 'require' with '__webpack_require__' // '__non_webpack_require__' is a proxy to Node 'require' // The below code is to ensure that the server is run only when not requiring the bundle. diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts index 891238bbed..afb92a9111 100644 --- a/src/app/access-control/access-control.module.ts +++ b/src/app/access-control/access-control.module.ts @@ -10,6 +10,16 @@ import { MembersListComponent } from './group-registry/group-form/members-list/m 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'; + +/** + * 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: [ @@ -26,6 +36,12 @@ import { FormModule } from '../shared/form/form.module'; GroupFormComponent, SubgroupsListComponent, MembersListComponent + ], + providers: [ + { + provide: DYNAMIC_ERROR_MESSAGES_MATCHER, + useValue: ValidateEmailErrorStateMatcher + }, ] }) /** 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 b8b1044362..a07ea37df2 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.actions.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.actions.ts @@ -1,3 +1,4 @@ +/* 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'; @@ -16,7 +17,6 @@ export const EPeopleRegistryActionTypes = { CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_EDIT_EPERSON'), }; -/* tslint:disable:max-classes-per-file */ /** * Used to edit an EPerson in the EPeople registry */ @@ -37,7 +37,6 @@ export class EPeopleRegistryCancelEPersonAction implements Action { type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group 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 7ef02a76cf..2d87f21d26 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -45,7 +45,7 @@ - + { let component: EPeopleRegistryComponent; 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 b99304d037..55233d8173 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -238,7 +238,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { if (restResponse.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name})); - this.reset(); } else { this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); } 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 41ae67423c..e9cc48aee3 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 @@ -36,12 +36,12 @@ - +
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
- + { }); groupsDataService = jasmine.createSpyObj('groupsDataService', { - findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '' }); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 05fc3189d0..7d607647e3 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -265,7 +265,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { if (eperson != null) { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, { + this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, { currentPage: 1, elementsPerPage: this.config.pageSize }); @@ -297,7 +297,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { }), switchMap(([eperson, findListOptions]) => { if (eperson != null) { - return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); + return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); } return observableOf(undefined); }) @@ -554,7 +554,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ private updateGroups(options) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); + this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options); })); } } diff --git a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index 2307f3c6fa..09487a7eaa 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms'; -import { BrowserModule } from '@angular/platform-browser'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { BrowserModule, By } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; @@ -35,6 +35,7 @@ import { RouterMock } from '../../../shared/mocks/router.mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { Operation } from 'fast-json-patch'; import { ValidateGroupExists } from './validators/group-exists.validator'; +import { NoContent } from '../../../core/shared/NoContent.model'; describe('GroupFormComponent', () => { let component: GroupFormComponent; @@ -87,6 +88,9 @@ describe('GroupFormComponent', () => { patch(group: Group, operations: Operation[]) { return null; }, + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return createSuccessfulRemoteDataObject$({}); + }, cancelEditGroup(): void { this.activeGroup = null; }, @@ -348,4 +352,46 @@ describe('GroupFormComponent', () => { }); }); + describe('delete', () => { + let deleteButton; + + beforeEach(() => { + component.initialisePage(); + + component.canEdit$ = observableOf(true); + component.groupBeingEdited = { + permanent: false + } as Group; + + fixture.detectChanges(); + deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement; + + spyOn(groupsDataServiceStub, 'delete').and.callThrough(); + spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' })); + }); + + describe('if confirmed via modal', () => { + beforeEach(waitForAsync(() => { + deleteButton.click(); + fixture.detectChanges(); + (document as any).querySelector('.modal-footer .confirm').click(); + })); + + it('should call GroupDataService.delete', () => { + expect(groupsDataServiceStub.delete).toHaveBeenCalledWith('active-group'); + }); + }); + + describe('if canceled via modal', () => { + beforeEach(waitForAsync(() => { + deleteButton.click(); + fixture.detectChanges(); + (document as any).querySelector('.modal-footer .cancel').click(); + })); + + it('should not call GroupDataService.delete', () => { + expect(groupsDataServiceStub.delete).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 826b7dbe69..584b28ba1e 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -46,6 +46,7 @@ import { followLink } from '../../../shared/utils/follow-link-config.model'; import { NoContent } from '../../../core/shared/NoContent.model'; import { Operation } from 'fast-json-patch'; import { ValidateGroupExists } from './validators/group-exists.validator'; +import { environment } from '../../../../environments/environment'; @Component({ selector: 'ds-group-form', @@ -194,6 +195,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { label: groupDescription, name: 'groupDescription', required: false, + spellCheck: environment.form.spellCheck, }); this.formModel = [ this.groupName, @@ -426,7 +428,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { .subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name })); - this.reset(); + this.onCancel(); } else { this.notificationsService.error( this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }), @@ -439,16 +441,6 @@ export class GroupFormComponent implements OnInit, OnDestroy { }); } - /** - * This method will ensure that the page gets reset and that the cache is cleared - */ - reset() { - this.groupDataService.getBrowseEndpoint().pipe(take(1)).subscribe((href: string) => { - this.requestService.removeByHrefSubstring(href); - }); - this.onCancel(); - } - /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts index 0b19b17100..8d0ddf0a85 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts @@ -53,7 +53,7 @@ describe('MembersListComponent', () => { activeGroup: activeGroup, epersonMembers: epersonMembers, subgroupMembers: subgroupMembers, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, searchByScope(scope: string, query: string): Observable>> { diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index 54d144da51..169d009d63 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -10,7 +10,7 @@ import { combineLatest as observableCombineLatest, ObservedValueOf, } from 'rxjs'; -import { map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; import {buildPaginatedList, PaginatedList} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; @@ -129,7 +129,7 @@ export class MembersListComponent implements OnInit, OnDestroy { this.subs.set(SubKey.MembersDTO, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((currentPagination) => { - return this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, { + return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { currentPage: currentPagination.currentPage, elementsPerPage: currentPagination.pageSize } @@ -144,7 +144,7 @@ export class MembersListComponent implements OnInit, OnDestroy { } }), switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dto$: Observable = observableCombineLatest( this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -153,8 +153,8 @@ export class MembersListComponent implements OnInit, OnDestroy { return epersonDtoModel; }); return dto$; - })); - return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); })) @@ -171,10 +171,10 @@ export class MembersListComponent implements OnInit, OnDestroy { return this.groupDataService.getActiveGroup().pipe(take(1), mergeMap((group: Group) => { if (group != null) { - return this.ePersonDataService.findAllByHref(group._links.epersons.href, { + return this.ePersonDataService.findListByHref(group._links.epersons.href, { currentPage: 1, elementsPerPage: 9999 - }, false) + }) .pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), @@ -209,7 +209,6 @@ export class MembersListComponent implements OnInit, OnDestroy { if (activeGroup != null) { const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup); - this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -274,7 +273,7 @@ export class MembersListComponent implements OnInit, OnDestroy { } }), switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dto$: Observable = observableCombineLatest( this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -283,8 +282,8 @@ export class MembersListComponent implements OnInit, OnDestroy { return epersonDtoModel; }); return dto$; - })); - return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); })) @@ -315,7 +314,6 @@ export class MembersListComponent implements OnInit, OnDestroy { response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject })); - this.ePersonDataService.clearLinkRequests(activeGroup._links.epersons.href); } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject })); } diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts index bee5126e09..1ca6c88c5f 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts @@ -65,7 +65,7 @@ describe('SubgroupsListComponent', () => { getSubgroups(): Group { return this.activeGroup; }, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { return this.subgroups$.pipe( map((currentGroups: Group[]) => { return createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), currentGroups)); diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts index 9930dd61ea..5f1700e07d 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts @@ -115,7 +115,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { this.subs.set( SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( - switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, { + switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, { currentPage: config.currentPage, elementsPerPage: config.pageSize }, @@ -139,7 +139,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup.uuid === possibleSubgroup.uuid) { return observableOf(false); } else { - return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, { + return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, { currentPage: 1, elementsPerPage: 9999 }) diff --git a/src/app/access-control/group-registry/group-registry.actions.ts b/src/app/access-control/group-registry/group-registry.actions.ts index bc1c0b97a6..8144bd0599 100644 --- a/src/app/access-control/group-registry/group-registry.actions.ts +++ b/src/app/access-control/group-registry/group-registry.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { Group } from '../../core/eperson/models/group.model'; import { type } from '../../shared/ngrx/type'; @@ -16,7 +17,6 @@ export const GroupRegistryActionTypes = { CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'), }; -/* tslint:disable:max-classes-per-file */ /** * Used to edit a Group in the Group registry */ @@ -37,7 +37,6 @@ export class GroupRegistryCancelGroupAction implements Action { type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/access-control/group-registry/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html index e791b7f2a0..ebbd223599 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -33,7 +33,7 @@
- + diff --git a/src/app/access-control/group-registry/groups-registry.component.spec.ts b/src/app/access-control/group-registry/groups-registry.component.spec.ts index 0b30a551fd..239939e70d 100644 --- a/src/app/access-control/group-registry/groups-registry.component.spec.ts +++ b/src/app/access-control/group-registry/groups-registry.component.spec.ts @@ -31,6 +31,7 @@ import { RouterMock } from '../../shared/mocks/router.mock'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { NoContent } from '../../core/shared/NoContent.model'; describe('GroupRegistryComponent', () => { let component: GroupsRegistryComponent; @@ -68,7 +69,7 @@ describe('GroupRegistryComponent', () => { mockGroups = [GroupMock, GroupMock2]; mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { switch (href) { case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons': return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ @@ -96,7 +97,7 @@ describe('GroupRegistryComponent', () => { }; groupsDataServiceStub = { allGroups: mockGroups, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { switch (href) { case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups': return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ @@ -145,7 +146,10 @@ describe('GroupRegistryComponent', () => { totalPages: 1, currentPage: 1 }), [result])); - } + }, + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return createSuccessfulRemoteDataObject$({}); + }, }; dsoDataServiceStub = { findByHref(href: string): Observable> { @@ -301,4 +305,29 @@ describe('GroupRegistryComponent', () => { }); }); }); + + describe('delete', () => { + let deleteButton; + + beforeEach(fakeAsync(() => { + spyOn(groupsDataServiceStub, 'delete').and.callThrough(); + + setIsAuthorized(true, true); + + // force rerender after setup changes + component.search({ query: '' }); + tick(); + fixture.detectChanges(); + + // only mockGroup[0] is deletable, so we should only get one button + deleteButton = fixture.debugElement.query(By.css('.btn-delete')).nativeElement; + })); + + it('should call GroupDataService.delete', () => { + deleteButton.click(); + fixture.detectChanges(); + + expect(groupsDataServiceStub.delete).toHaveBeenCalledWith(mockGroups[0].id); + }); + }); }); diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index da861518da..70c9b22852 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -5,11 +5,12 @@ import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest as observableCombineLatest, + EMPTY, Observable, of as observableOf, Subscription } from 'rxjs'; -import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, defaultIfEmpty, map, switchMap, tap } from 'rxjs/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; @@ -144,7 +145,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe( switchMap((isSiteAdmin: boolean) => { - return observableCombineLatest(groups.page.map((group: Group) => { + return observableCombineLatest([...groups.page.map((group: Group) => { if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) { return observableCombineLatest([ this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self), @@ -165,8 +166,10 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } ) ); + } else { + return EMPTY; } - })).pipe(map((dtos: GroupDtoModel[]) => { + })]).pipe(defaultIfEmpty([]), map((dtos: GroupDtoModel[]) => { return buildPaginatedList(groups.pageInfo, dtos); })); }) @@ -199,7 +202,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { if (rd.hasSucceeded) { this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id]; this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name })); - this.reset(); } else { this.notificationsService.error( this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }), @@ -209,23 +211,12 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } } - /** - * This method will set everything to stale, which will cause the lists on this page to update. - */ - reset() { - this.groupService.getBrowseEndpoint().pipe( - take(1) - ).subscribe((href: string) => { - this.requestService.setStaleByHrefSubstring(href); - }); - } - /** * Get the members (epersons embedded value of a group) * @param group */ getMembers(group: Group): Observable>> { - return this.ePersonDataService.findAllByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); + return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); } /** @@ -233,7 +224,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { * @param group */ getSubgroups(group: Group): Observable>> { - return this.groupService.findAllByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); + return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); } /** diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html new file mode 100644 index 0000000000..dbc8c74437 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html @@ -0,0 +1,35 @@ +
+ +

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

+

+ selected collection: {{getDspaceObjectName()}}  + {{'admin.batch-import.page.remove' | translate}} +

+

+ +

+
+
+ + +
+ + {{'admin.batch-import.page.validateOnly.hint' | translate}} + +
+ + + + +
+ + +
+
diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts new file mode 100644 index 0000000000..36ba1137c9 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts @@ -0,0 +1,151 @@ +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { BatchImportPageComponent } from './batch-import-page.component'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive'; +import { FileValidator } from '../../shared/utils/require-file.validator'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + BATCH_IMPORT_SCRIPT_NAME, + ScriptDataService +} from '../../core/data/processes/script-data.service'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; + +describe('BatchImportPageComponent', () => { + let component: BatchImportPageComponent; + let fixture: ComponentFixture; + + let notificationService: NotificationsServiceStub; + let scriptService: any; + let router; + let locationStub; + + function init() { + notificationService = new NotificationsServiceStub(); + scriptService = jasmine.createSpyObj('scriptService', + { + invoke: createSuccessfulRemoteDataObject$({ processId: '46' }) + } + ); + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl') + }); + locationStub = jasmine.createSpyObj('location', { + back: jasmine.createSpy('back') + }); + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]) + ], + declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator], + providers: [ + { provide: NotificationsService, useValue: notificationService }, + { provide: ScriptDataService, useValue: scriptService }, + { provide: Router, useValue: router }, + { provide: Location, useValue: locationStub }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BatchImportPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('if back button is pressed', () => { + beforeEach(fakeAsync(() => { + const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('should do location.back', () => { + expect(locationStub.back).toHaveBeenCalled(); + }); + }); + + describe('if file is set', () => { + let fileMock: File; + + beforeEach(() => { + fileMock = new File([''], 'filename.zip', { type: 'application/zip' }); + component.setFile(fileMock); + }); + + describe('if proceed button is pressed without validate only', () => { + beforeEach(fakeAsync(() => { + component.validateOnly = false; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with --zip fileName and the mockFile', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), + ]; + parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' })); + expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46'); + }); + }); + + describe('if proceed button is pressed with validate only', () => { + beforeEach(fakeAsync(() => { + component.validateOnly = true; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), + Object.assign(new ProcessParameter(), { name: '--add' }), + Object.assign(new ProcessParameter(), { name: '-v', value: true }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46'); + }); + }); + + describe('if proceed is pressed; but script invoke fails', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500)); + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('error notification is shown', () => { + expect(notificationService.error).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts new file mode 100644 index 0000000000..7171c67585 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts @@ -0,0 +1,124 @@ +import { Component } from '@angular/core'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service'; +import { Router } from '@angular/router'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { Process } from '../../process-page/processes/process.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; +import { + ImportBatchSelectorComponent +} from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { take } from 'rxjs/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +@Component({ + selector: 'ds-batch-import-page', + templateUrl: './batch-import-page.component.html' +}) +export class BatchImportPageComponent { + /** + * The current value of the file + */ + fileObject: File; + + /** + * The validate only flag + */ + validateOnly = true; + /** + * dso object for community or collection + */ + dso: DSpaceObject = null; + + public constructor(private location: Location, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + private scriptDataService: ScriptDataService, + private router: Router, + private modalService: NgbModal, + private dsoNameService: DSONameService) { + } + + /** + * Set file + * @param file + */ + setFile(file) { + this.fileObject = file; + } + + /** + * When return button is pressed go to previous location + */ + public onReturn() { + this.location.back(); + } + + public selectCollection() { + const modalRef = this.modalService.open(ImportBatchSelectorComponent); + modalRef.componentInstance.response.pipe(take(1)).subscribe((dso) => { + this.dso = dso || null; + }); + } + + /** + * Starts import-metadata script with --zip fileName (and the selected file) + */ + public importMetadata() { + if (this.fileObject == null) { + this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile')); + } else { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }), + Object.assign(new ProcessParameter(), { name: '--add' }) + ]; + if (this.dso) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid })); + } + if (this.validateOnly) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); + } + + this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + const title = this.translate.get('process.new.notification.success.title'); + const content = this.translate.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + if (isNotEmpty(rd.payload)) { + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } + } else { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + } + }); + } + } + + /** + * return selected dspace object name + */ + getDspaceObjectName(): string { + if (this.dso) { + return this.dsoNameService.getName(this.dso); + } + return null; + } + + /** + * remove selected dso object + */ + removeDspaceObject(): void { + this.dso = null; + } +} diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html index 42a04b0de6..24901cc11d 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html @@ -1,6 +1,17 @@

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

+
+
+ + +
+ + {{'admin.metadata-import.page.validateOnly.hint' | translate}} + +
- - +
+ + +
diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts index d663481b8c..814757ec71 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts @@ -87,8 +87,9 @@ describe('MetadataImportPageComponent', () => { comp.setFile(fileMock); }); - describe('if proceed button is pressed', () => { + describe('if proceed button is pressed without validate only', () => { beforeEach(fakeAsync(() => { + comp.validateOnly = false; const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; proceed.click(); fixture.detectChanges(); @@ -107,6 +108,28 @@ describe('MetadataImportPageComponent', () => { }); }); + describe('if proceed button is pressed with validate only', () => { + beforeEach(fakeAsync(() => { + comp.validateOnly = true; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with -f fileName and the mockFile and -v validate-only', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-f', value: 'filename.txt' }), + Object.assign(new ProcessParameter(), { name: '-v', value: true }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45'); + }); + }); + describe('if proceed is pressed; but script invoke fails', () => { beforeEach(fakeAsync(() => { jasmine.getEnv().allowRespy(true); diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts index 3bdcca3084..4236d152dc 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts @@ -1,12 +1,8 @@ import { Location } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; -import { AuthService } from '../../core/auth/auth.service'; import { METADATA_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -30,6 +26,11 @@ export class MetadataImportPageComponent { */ fileObject: File; + /** + * The validate only flag + */ + validateOnly = true; + public constructor(private location: Location, protected translate: TranslateService, protected notificationsService: NotificationsService, @@ -62,6 +63,9 @@ export class MetadataImportPageComponent { const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }), ]; + if (this.validateOnly) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); + } this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( getFirstCompletedRemoteData(), diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts index 84917905d3..c5bebb292b 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; @@ -17,7 +18,6 @@ export const BitstreamFormatsRegistryActionTypes = { DESELECT_ALL_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_ALL_FORMAT') }; -/* tslint:disable:max-classes-per-file */ /** * Used to select a single bitstream format in the bitstream format registry */ @@ -51,7 +51,6 @@ export class BitstreamFormatsRegistryDeselectAllAction implements Action { type = BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html index 6569b2d4c8..c4304806ce 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -13,32 +13,34 @@ [paginationOptions]="pageConfig" [pageInfoState]="(bitstreamFormats | async)?.payload" [collectionSize]="(bitstreamFormats | async)?.payload?.totalElements" - [hideGear]="true" + [hideGear]="false" [hidePagerWhenSinglePage]="true">
- - - - - - + + + + + + + - - - - - - + + + + + + +
{{'admin.registries.bitstream-formats.table.name' | translate}}{{'admin.registries.bitstream-formats.table.mimetype' | translate}}{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}
{{'admin.registries.bitstream-formats.table.id' | translate}}{{'admin.registries.bitstream-formats.table.name' | translate}}{{'admin.registries.bitstream-formats.table.mimetype' | translate}}{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}
- - {{bitstreamFormat.shortDescription}}{{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.table.internal' | translate}}){{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}
+ + {{bitstreamFormat.id}}{{bitstreamFormat.shortDescription}}{{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.table.internal' | translate}}){{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}
diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 8cfba1d37b..8a44240b7e 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -20,12 +20,10 @@ import { TestScheduler } from 'rxjs/testing'; import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ + createSuccessfulRemoteDataObject$, + createFailedRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../../core/data/request.models'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; @@ -85,10 +83,6 @@ describe('BitstreamFormatsComponent', () => { ]; const mockFormatsRD = createSuccessfulRemoteDataObject(createPaginatedList(mockFormatsList)); - const pagination = Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }); - const sort = new SortOptions('score', SortDirection.DESC); - const findlistOptions = Object.assign(new FindListOptions(), { currentPage: 1, elementsPerPage: 20 }); - const initAsync = () => { notificationsServiceStub = new NotificationsServiceStub(); @@ -135,16 +129,19 @@ describe('BitstreamFormatsComponent', () => { }); it('should contain the correct formats', () => { - const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement; + const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(3)')).nativeElement; expect(unknownName.textContent).toBe('Unknown'); - const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(2)')).nativeElement; + const UUID: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement; + expect(UUID.textContent).toBe('test-uuid-1'); + + const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(3)')).nativeElement; expect(licenseName.textContent).toBe('License'); - const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(2)')).nativeElement; + const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(3)')).nativeElement; expect(ccLicenseName.textContent).toBe('CC License'); - const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(2)')).nativeElement; + const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(3)')).nativeElement; expect(adobeName.textContent).toBe('Adobe PDF'); }); }); @@ -246,7 +243,7 @@ describe('BitstreamFormatsComponent', () => { )); beforeEach(initBeforeEach); - it('should clear bitstream formats ', () => { + it('should clear bitstream formats and show a success notification', () => { comp.deleteFormats(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); @@ -275,7 +272,7 @@ describe('BitstreamFormatsComponent', () => { selectBitstreamFormat: {}, deselectBitstreamFormat: {}, deselectAllBitstreamFormats: {}, - delete: observableOf(false), + delete: createFailedRemoteDataObject$(), clearBitStreamFormatRequests: observableOf('cleared') }); @@ -295,7 +292,7 @@ describe('BitstreamFormatsComponent', () => { )); beforeEach(initBeforeEach); - it('should clear bitstream formats ', () => { + it('should clear bitstream formats and show an error notification', () => { comp.deleteFormats(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index cbbcbe07a4..162bf2bdb2 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -1,18 +1,18 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable, zip } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable} from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; -import { FindListOptions } from '../../../core/data/request.models'; -import { map, switchMap, take } from 'rxjs/operators'; -import { hasValue } from '../../../shared/empty.util'; +import { map, mergeMap, switchMap, take, toArray } from 'rxjs/operators'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { NoContent } from '../../../core/shared/NoContent.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; /** * This component renders a list of bitstream formats @@ -28,21 +28,14 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { */ bitstreamFormats: Observable>>; - /** - * The current pagination configuration for the page used by the FindAll method - * Currently simply renders all bitstream formats - */ - config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 20 - }); - /** * The current pagination configuration for the page * Currently simply renders all bitstream formats */ pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'rbp', - pageSize: 20 + pageSize: 20, + pageSizeOptions: [20, 40, 60, 80, 100] }); constructor(private notificationsService: NotificationsService, @@ -50,7 +43,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { private translateService: TranslateService, private bitstreamFormatService: BitstreamFormatDataService, private paginationService: PaginationService, - ) { + ) { } @@ -58,31 +51,39 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { * Deletes the currently selected formats from the registry and updates the presented list */ deleteFormats() { - this.bitstreamFormatService.clearBitStreamFormatRequests().subscribe(); - this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(take(1)).subscribe( - (formats) => { - const tasks$ = []; - for (const format of formats) { - if (hasValue(format.id)) { - tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RemoteData) => response.hasSucceeded))); - } - } - zip(...tasks$).subscribe((results: boolean[]) => { - const successResponses = results.filter((result: boolean) => result); - const failedResponses = results.filter((result: boolean) => !result); - if (successResponses.length > 0) { - this.showNotification(true, successResponses.length); - } - if (failedResponses.length > 0) { - this.showNotification(false, failedResponses.length); - } + this.bitstreamFormatService.clearBitStreamFormatRequests(); + this.bitstreamFormatService.getSelectedBitstreamFormats().pipe( + take(1), + // emit all formats in the array one at a time + mergeMap((formats: BitstreamFormat[]) => formats), + // delete each format + mergeMap((format: BitstreamFormat) => this.bitstreamFormatService.delete(format.id).pipe( + // wait for each response to come back + getFirstCompletedRemoteData(), + // return a boolean to indicate whether a response succeeded + map((response: RemoteData) => response.hasSucceeded), + )), + // wait for all responses to come in and return them as a single array + toArray() + ).subscribe((results: boolean[]) => { + // Count the number of succeeded and failed deletions + const successResponses = results.filter((result: boolean) => result); + const failedResponses = results.filter((result: boolean) => !result); - this.deselectAll(); - - this.paginationService.resetPage(this.pageConfig.id); - }); + // Show a notification indicating the number of succeeded and failed deletions + if (successResponses.length > 0) { + this.showNotification(true, successResponses.length); } - ); + if (failedResponses.length > 0) { + this.showNotification(false, failedResponses.length); + } + + // reset the selection + this.deselectAll(); + + // reload the page + this.paginationService.resetPage(this.pageConfig.id); + }); } /** @@ -140,7 +141,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { ngOnInit(): void { - this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe( + this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe( switchMap((findListOptions: FindListOptions) => { return this.bitstreamFormatService.findAll(findListOptions); }) diff --git a/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.ts b/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.ts index 161cfa7ecf..142f6fb83d 100644 --- a/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.ts +++ b/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.ts @@ -15,6 +15,7 @@ import { Router } from '@angular/router'; import { hasValue, isEmpty } from '../../../../shared/empty.util'; import { TranslateService } from '@ngx-translate/core'; import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths'; +import { environment } from '../../../../../environments/environment'; /** * The component responsible for rendering the form to create/edit a bitstream format @@ -90,6 +91,7 @@ export class FormatFormComponent implements OnInit { name: 'description', label: 'admin.registries.bitstream-formats.edit.description.label', hint: 'admin.registries.bitstream-formats.edit.description.hint', + spellCheck: environment.form.spellCheck, }), new DynamicSelectModel({ diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts index 9737928a13..3fc872ca43 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; @@ -26,7 +27,6 @@ export const MetadataRegistryActionTypes = { DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD') }; -/* tslint:disable:max-classes-per-file */ /** * Used to edit a metadata schema in the metadata registry */ @@ -133,7 +133,6 @@ export class MetadataRegistryDeselectAllFieldAction implements Action { type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts index 0253725cb9..944288a7a5 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -19,9 +19,6 @@ import { RestResponse } from '../../../core/cache/response.models'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { PaginationService } from '../../../core/pagination/pagination.service'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../../core/data/request.models'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; describe('MetadataRegistryComponent', () => { @@ -52,7 +49,7 @@ describe('MetadataRegistryComponent', () => { } ]; const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getMetadataSchemas: () => mockSchemas, getActiveMetadataSchema: () => observableOf(undefined), @@ -66,7 +63,7 @@ describe('MetadataRegistryComponent', () => { }, clearMetadataSchemaRequests: () => observableOf(undefined) }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ paginationService = new PaginationServiceStub(); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts index 8574c4678b..857034604e 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts @@ -128,7 +128,6 @@ export class MetadataRegistryComponent { * Delete all the selected metadata schemas */ deleteSchemas() { - this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( (schemas) => { const tasks$ = []; @@ -148,7 +147,6 @@ export class MetadataRegistryComponent { } this.registryService.deselectAllMetadataSchema(); this.registryService.cancelEditMetadataSchema(); - this.forceUpdateSchemas(); }); } ); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index f486c3c132..8d416c2df8 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -17,7 +17,7 @@ describe('MetadataSchemaFormComponent', () => { let fixture: ComponentFixture; let registryService: RegistryService; - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getActiveMetadataSchema: () => observableOf(undefined), createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), @@ -33,7 +33,7 @@ describe('MetadataSchemaFormComponent', () => { }; } }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 1bd25be113..e13180d633 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -24,7 +24,7 @@ describe('MetadataFieldFormComponent', () => { prefix: 'fake' }); - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getActiveMetadataField: () => observableOf(undefined), createMetadataField: (field: MetadataField) => observableOf(field), @@ -43,7 +43,7 @@ describe('MetadataFieldFormComponent', () => { }; } }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html index 49fed0c847..1a867928f5 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html @@ -25,6 +25,7 @@ + {{'admin.registries.schema.fields.table.id' | translate}} {{'admin.registries.schema.fields.table.field' | translate}} {{'admin.registries.schema.fields.table.scopenote' | translate}} @@ -39,6 +40,7 @@ (change)="selectMetadataField(field, $event)"> + {{field.id}} {{schema?.prefix}}.{{field.element}}{{field.qualifier}} {{field.scopeNote}} diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index 6eb3c5b1a4..2b660a6363 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -23,9 +23,6 @@ import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { MetadataField } from '../../../core/metadata/metadata-field.model'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { VarDirective } from '../../../shared/utils/var.directive'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../../core/data/request.models'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; @@ -106,7 +103,7 @@ describe('MetadataSchemaComponent', () => { } ]; const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getMetadataSchemas: () => mockSchemas, getMetadataFieldsBySchema: (schema: MetadataSchema) => createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))), @@ -122,7 +119,7 @@ describe('MetadataSchemaComponent', () => { }, clearMetadataFieldRequests: () => observableOf(undefined) }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ const schemaNameParam = 'mock'; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { params: observableOf({ @@ -169,10 +166,10 @@ describe('MetadataSchemaComponent', () => { }); it('should contain the correct fields', () => { - const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(2)')).nativeElement; + const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(3)')).nativeElement; expect(editorField.textContent).toBe('mock.contributor.editor'); - const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(2)')).nativeElement; + const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(3)')).nativeElement; expect(illustratorField.textContent).toBe('mock.contributor.illustrator'); }); diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts index 8a2086d5e2..d0827e6e4d 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -174,15 +174,12 @@ export class MetadataSchemaComponent implements OnInit { const failedResponses = responses.filter((response: RemoteData) => response.hasFailed); if (successResponses.length > 0) { this.showNotification(true, successResponses.length); - this.registryService.clearMetadataFieldRequests(); - } if (failedResponses.length > 0) { this.showNotification(false, failedResponses.length); } this.registryService.deselectAllMetadataField(); this.registryService.cancelEditMetadataField(); - this.forceUpdateFields(); }); } ); diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index ee5cb8737b..1ea20bc9a0 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -7,6 +7,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; +import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ @@ -40,6 +41,12 @@ import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; component: MetadataImportPageComponent, data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' } }, + { + path: 'batch-import', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: BatchImportPageComponent, + data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' } + }, ]) ], providers: [ diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts index 2cb0413bbc..1ea27b36b6 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts @@ -14,6 +14,14 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; describe('CollectionAdminSearchResultGridElementComponent', () => { let component: CollectionAdminSearchResultGridElementComponent; @@ -45,7 +53,11 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts index 17ce2cd7a1..996366e20a 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts @@ -16,6 +16,14 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s import { Community } from '../../../../../core/shared/community.model'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; describe('CommunityAdminSearchResultGridElementComponent', () => { let component: CommunityAdminSearchResultGridElementComponent; @@ -47,7 +55,11 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index dedada5f5f..e478aa3ef3 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,6 +18,14 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service'; +import { AccessStatusObject } from '../../../../../shared/object-list/access-status-badge/access-status.model'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -31,6 +39,12 @@ describe('ItemAdminSearchResultGridElementComponent', () => { } }; + const mockAccessStatusDataService = { + findAccessStatusFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new AccessStatusObject()); + } + }; + const mockThemeService = getMockThemeService(); function init() { @@ -55,6 +69,10 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, + { provide: AccessStatusDataService, useValue: mockAccessStatusDataService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts index b394caef56..8937847ff5 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts @@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; describe('CollectionAdminSearchResultListElementComponent', () => { let component: CollectionAdminSearchResultListElementComponent; @@ -36,7 +38,8 @@ describe('CollectionAdminSearchResultListElementComponent', () => { ], declarations: [CollectionAdminSearchResultListElementComponent], providers: [{ provide: TruncatableService, useValue: {} }, - { provide: DSONameService, useClass: DSONameServiceMock }], + { provide: DSONameService, useClass: DSONameServiceMock }, + { provide: APP_CONFIG, useValue: environment }], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts index 155d7f7509..110d77b1e5 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts @@ -13,6 +13,8 @@ import { Community } from '../../../../../core/shared/community.model'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; describe('CommunityAdminSearchResultListElementComponent', () => { let component: CommunityAdminSearchResultListElementComponent; @@ -36,7 +38,8 @@ describe('CommunityAdminSearchResultListElementComponent', () => { ], declarations: [CommunityAdminSearchResultListElementComponent], providers: [{ provide: TruncatableService, useValue: {} }, - { provide: DSONameService, useClass: DSONameServiceMock }], + { provide: DSONameService, useClass: DSONameServiceMock }, + { provide: APP_CONFIG, useValue: environment }], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts index 3774a07757..667e8edea9 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts @@ -10,6 +10,8 @@ import { ItemAdminSearchResultListElementComponent } from './item-admin-search-r import { Item } from '../../../../../core/shared/item.model'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; describe('ItemAdminSearchResultListElementComponent', () => { let component: ItemAdminSearchResultListElementComponent; @@ -33,7 +35,8 @@ describe('ItemAdminSearchResultListElementComponent', () => { ], declarations: [ItemAdminSearchResultListElementComponent], providers: [{ provide: TruncatableService, useValue: {} }, - { provide: DSONameService, useClass: DSONameServiceMock }], + { provide: DSONameService, useClass: DSONameServiceMock }, + { provide: APP_CONFIG, useValue: environment }], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html index fbbbbb255c..ba4ab15363 100644 --- a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html +++ b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html @@ -1,28 +1,30 @@ - - {{"admin.search.item.move" | translate}} - + diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts index 14d5d38199..1e28b62626 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MenuService } from '../../../shared/menu/menu.service'; import { MenuServiceStub } from '../../../shared/testing/menu-service.stub'; -import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; +import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub'; import { Component } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts index 50f9f8a79e..47f693cb99 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -1,10 +1,10 @@ import { Component, Inject, Injector, OnInit } from '@angular/core'; import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component'; -import { MenuID } from '../../../shared/menu/initial-menus-state'; import { MenuService } from '../../../shared/menu/menu.service'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; -import { MenuSection } from '../../../shared/menu/menu.reducer'; +import { MenuSection } from '../../../shared/menu/menu-section.model'; +import { MenuID } from '../../../shared/menu/menu-id.model'; import { isNotEmpty } from '../../../shared/empty.util'; import { Router } from '@angular/router'; @@ -12,7 +12,7 @@ import { Router } from '@angular/router'; * Represents a non-expandable section in the admin sidebar */ @Component({ - /* tslint:disable:component-selector */ + /* eslint-disable @angular-eslint/component-selector */ selector: 'li[ds-admin-sidebar-section]', templateUrl: './admin-sidebar-section.component.html', styleUrls: ['./admin-sidebar-section.component.scss'], diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.html b/src/app/admin/admin-sidebar/admin-sidebar.component.html index 84402c64e9..ef220b834b 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.html @@ -1,4 +1,4 @@ - - + diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts index 7b0ddcb18e..5c2a6d820e 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts @@ -18,11 +18,10 @@ import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search- import { toRemoteData } from '../browse-by-metadata-page/browse-by-metadata-page.component.spec'; import { VarDirective } from '../../shared/utils/var.directive'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; describe('BrowseByDatePageComponent', () => { let comp: BrowseByDatePageComponent; @@ -83,7 +82,8 @@ describe('BrowseByDatePageComponent', () => { { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, { provide: PaginationService, useValue: paginationService }, - { provide: ChangeDetectorRef, useValue: mockCdRef } + { provide: ChangeDetectorRef, useValue: mockCdRef }, + { provide: APP_CONFIG, useValue: environment } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts index 1bdbb91a8b..c4a67349a5 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts @@ -1,9 +1,8 @@ -import { ChangeDetectorRef, Component } from '@angular/core'; +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; import { BrowseByMetadataPageComponent, - browseParamsToOptions + browseParamsToOptions, getBrowseSearchOptions } from '../browse-by-metadata-page/browse-by-metadata-page.component'; -import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { combineLatest as observableCombineLatest } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; @@ -12,12 +11,12 @@ import { ActivatedRoute, Params, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; -import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; -import { environment } from '../../../environments/environment'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { isValidDate } from '../../shared/date.util'; +import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; @Component({ selector: 'ds-browse-by-date-page', @@ -29,7 +28,6 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * An example would be 'dateissued' for 'dc.date.issued' */ -@rendersBrowseBy(BrowseByDataType.Date) export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { /** @@ -42,14 +40,16 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { protected dsoService: DSpaceObjectDataService, protected router: Router, protected paginationService: PaginationService, - protected cdRef: ChangeDetectorRef) { - super(route, browseService, dsoService, paginationService, router); + protected cdRef: ChangeDetectorRef, + @Inject(APP_CONFIG) public appConfig: AppConfig) { + super(route, browseService, dsoService, paginationService, router, appConfig); } ngOnInit(): void { const sortConfig = new SortOptions('default', SortDirection.ASC); this.startsWithType = StartsWithType.date; - this.updatePage(new BrowseEntrySearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig)); + // include the thumbnail configuration in browse search options + this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails)); this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( @@ -62,7 +62,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; this.browseId = params.id || this.defaultBrowseId; this.startsWith = +params.startsWith || params.startsWith; - const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId); + const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails); this.updatePageWithItems(searchOptions, this.value, undefined); this.updateParent(params.scope); this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope); @@ -82,19 +82,19 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) { this.subs.push( this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { - let lowerLimit = environment.browseBy.defaultLowerLimit; + let lowerLimit = this.appConfig.browseBy.defaultLowerLimit; if (hasValue(firstItemRD.payload)) { const date = firstItemRD.payload.firstMetadataValue(metadataKeys); - if (hasValue(date)) { + if (isNotEmpty(date) && isValidDate(date)) { const dateObj = new Date(date); // TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC. - lowerLimit = dateObj.getUTCFullYear(); + lowerLimit = isNaN(dateObj.getUTCFullYear()) ? lowerLimit : dateObj.getUTCFullYear(); } } const options = []; const currentYear = new Date().getUTCFullYear(); - const oneYearBreak = Math.floor((currentYear - environment.browseBy.oneYearLimit) / 5) * 5; - const fiveYearBreak = Math.floor((currentYear - environment.browseBy.fiveYearLimit) / 10) * 10; + const oneYearBreak = Math.floor((currentYear - this.appConfig.browseBy.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((currentYear - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; if (lowerLimit <= fiveYearBreak) { lowerLimit -= 10; } else if (lowerLimit <= oneYearBreak) { diff --git a/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts new file mode 100644 index 0000000000..8eeae0c5de --- /dev/null +++ b/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; + +/** + * Themed wrapper for BrowseByDatePageComponent + * */ +@Component({ + selector: 'ds-themed-browse-by-metadata-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +@rendersBrowseBy(BrowseByDataType.Date) +export class ThemedBrowseByDatePageComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'BrowseByDatePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-date-page/browse-by-date-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-date-page.component`); + } +} diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index fc483d87e2..933c95a3cb 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -1,7 +1,6 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; import { of as observableOf } from 'rxjs'; -import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator'; diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html index 2321da0204..227fa8aa78 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html @@ -6,10 +6,10 @@ - - + @@ -18,24 +18,29 @@ - +
diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts index 60d2fa549b..2984642b73 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts @@ -1,4 +1,8 @@ -import { BrowseByMetadataPageComponent, browseParamsToOptions } from './browse-by-metadata-page.component'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions, + getBrowseSearchOptions +} from './browse-by-metadata-page.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BrowseService } from '../../core/browse/browse.service'; import { CommonModule } from '@angular/common'; @@ -14,7 +18,7 @@ import { RemoteData } from '../../core/data/remote-data'; import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; import { PageInfo } from '../../core/shared/page-info.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { SortDirection } from '../../core/cache/models/sort-options.model'; import { Item } from '../../core/shared/item.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { Community } from '../../core/shared/community.model'; @@ -25,6 +29,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { APP_CONFIG } from '../../../config/app-config.interface'; describe('BrowseByMetadataPageComponent', () => { let comp: BrowseByMetadataPageComponent; @@ -43,6 +48,13 @@ describe('BrowseByMetadataPageComponent', () => { ] }); + const environmentMock = { + browseBy: { + showThumbnails: true, + pageSize: 10 + } + }; + const mockEntries = [ { type: BrowseEntry.type, @@ -97,7 +109,8 @@ describe('BrowseByMetadataPageComponent', () => { { provide: BrowseService, useValue: mockBrowseService }, { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: PaginationService, useValue: paginationService }, - { provide: Router, useValue: new RouterMock() } + { provide: Router, useValue: new RouterMock() }, + { provide: APP_CONFIG, useValue: environmentMock } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -118,6 +131,10 @@ describe('BrowseByMetadataPageComponent', () => { expect(comp.items$).toBeUndefined(); }); + it('should set embed thumbnail property to true', () => { + expect(comp.fetchThumbnails).toBeTrue(); + }); + describe('when a value is provided', () => { beforeEach(() => { const paramsWithValue = { @@ -145,14 +162,14 @@ describe('BrowseByMetadataPageComponent', () => { }; const paginationOptions = Object.assign(new PaginationComponentOptions(), { currentPage: 5, - pageSize: 10, + pageSize: comp.appConfig.browseBy.pageSize, }); const sortOptions = { direction: SortDirection.ASC, field: 'fake-field', }; - result = browseParamsToOptions(paramsScope, paginationOptions, sortOptions, 'author'); + result = browseParamsToOptions(paramsScope, paginationOptions, sortOptions, 'author', comp.fetchThumbnails); }); it('should return BrowseEntrySearchOptions with the correct properties', () => { @@ -163,6 +180,36 @@ describe('BrowseByMetadataPageComponent', () => { expect(result.sort.direction).toEqual(SortDirection.ASC); expect(result.sort.field).toEqual('fake-field'); expect(result.scope).toEqual('fake-scope'); + expect(result.fetchThumbnail).toBeTrue(); + }); + }); + + describe('calling getBrowseSearchOptions', () => { + let result: BrowseEntrySearchOptions; + + beforeEach(() => { + const paramsScope = { + scope: 'fake-scope' + }; + const paginationOptions = Object.assign(new PaginationComponentOptions(), { + currentPage: 5, + pageSize: comp.appConfig.browseBy.pageSize, + }); + const sortOptions = { + direction: SortDirection.ASC, + field: 'fake-field', + }; + + result = getBrowseSearchOptions('title', paginationOptions, sortOptions, comp.fetchThumbnails); + }); + it('should return BrowseEntrySearchOptions with the correct properties', () => { + + expect(result.metadataDefinition).toEqual('title'); + expect(result.pagination.currentPage).toEqual(5); + expect(result.pagination.pageSize).toEqual(10); + expect(result.sort.direction).toEqual(SortDirection.ASC); + expect(result.sort.field).toEqual('fake-field'); + expect(result.fetchThumbnail).toBeTrue(); }); }); }); diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts index f789389697..4cfe332da1 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -1,5 +1,5 @@ import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, OnDestroy } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -14,9 +14,11 @@ import { getFirstSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; -import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; + +export const BBM_PAGINATION_ID = 'bbm'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -24,12 +26,12 @@ import { map } from 'rxjs/operators'; templateUrl: './browse-by-metadata-page.component.html' }) /** - * Component for browsing (items) by metadata definition - * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. - * An example would be 'author' for 'dc.contributor.*' + * Component for browsing (items) by metadata definition. + * A metadata definition (a.k.a. browse id) is a short term used to describe one + * or multiple metadata fields. An example would be 'author' for + * 'dc.contributor.*' */ -@rendersBrowseBy(BrowseByDataType.Metadata) -export class BrowseByMetadataPageComponent implements OnInit { +export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { /** * The list of browse-entries to display @@ -49,11 +51,7 @@ export class BrowseByMetadataPageComponent implements OnInit { /** * The pagination config used to display the values */ - paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'bbm', - currentPage: 1, - pageSize: 20 - }); + paginationConfig: PaginationComponentOptions; /** * The pagination observable @@ -93,7 +91,7 @@ export class BrowseByMetadataPageComponent implements OnInit { startsWithOptions; /** - * The value we're browing items for + * The value we're browsing items for * - When the value is not empty, we're browsing items * - When the value is empty, we're browsing browse-entries (values for the given metadata definition) */ @@ -109,16 +107,31 @@ export class BrowseByMetadataPageComponent implements OnInit { */ startsWith: string; + /** + * Determines whether to request embedded thumbnail. + */ + fetchThumbnails: boolean; + public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, protected dsoService: DSpaceObjectDataService, protected paginationService: PaginationService, - protected router: Router) { - } + protected router: Router, + @Inject(APP_CONFIG) public appConfig: AppConfig) { + + this.fetchThumbnails = this.appConfig.browseBy.showThumbnails; + this.paginationConfig = Object.assign(new PaginationComponentOptions(), { + id: BBM_PAGINATION_ID, + currentPage: 1, + pageSize: this.appConfig.browseBy.pageSize, + }); + } + ngOnInit(): void { + const sortConfig = new SortOptions('default', SortDirection.ASC); - this.updatePage(new BrowseEntrySearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig)); + this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig)); this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( @@ -127,19 +140,20 @@ export class BrowseByMetadataPageComponent implements OnInit { return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id || this.defaultBrowseId; this.authority = params.authority; - this.value = +params.value || params.value || ''; - this.startsWith = +params.startsWith || params.startsWith; - const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId); + this.value = +params.value || params.value || ''; + this.startsWith = +params.startsWith || params.startsWith; if (isNotEmpty(this.value)) { - this.updatePageWithItems(searchOptions, this.value, this.authority); + this.updatePageWithItems( + browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); } else { - this.updatePage(searchOptions); + this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false)); } this.updateParent(params.scope); })); this.updateStartsWithTextOptions(); + } /** @@ -226,22 +240,44 @@ export class BrowseByMetadataPageComponent implements OnInit { } +/** + * Creates browse entry search options. + * @param defaultBrowseId the metadata definition to fetch entries or items for + * @param paginationConfig the required pagination configuration + * @param sortConfig the required sort configuration + * @param fetchThumbnails optional boolean for fetching thumbnails + * @returns BrowseEntrySearchOptions instance + */ +export function getBrowseSearchOptions(defaultBrowseId: string, + paginationConfig: PaginationComponentOptions, + sortConfig: SortOptions, + fetchThumbnails?: boolean) { + if (!hasValue(fetchThumbnails)) { + fetchThumbnails = false; + } + return new BrowseEntrySearchOptions(defaultBrowseId, paginationConfig, sortConfig, null, + null, fetchThumbnails); +} + /** * Function to transform query and url parameters into searchOptions used to fetch browse entries or items * @param params URL and query parameters * @param paginationConfig Pagination configuration * @param sortConfig Sorting configuration * @param metadata Optional metadata definition to fetch browse entries/items for + * @param fetchThumbnail Optional parameter for requesting thumbnail images */ export function browseParamsToOptions(params: any, paginationConfig: PaginationComponentOptions, sortConfig: SortOptions, - metadata?: string): BrowseEntrySearchOptions { + metadata?: string, + fetchThumbnail?: boolean): BrowseEntrySearchOptions { return new BrowseEntrySearchOptions( metadata, paginationConfig, sortConfig, +params.startsWith || params.startsWith, - params.scope + params.scope, + fetchThumbnail ); } diff --git a/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts new file mode 100644 index 0000000000..b0679258e9 --- /dev/null +++ b/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { BrowseByMetadataPageComponent } from './browse-by-metadata-page.component'; +import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; + +/** + * Themed wrapper for BrowseByMetadataPageComponent + **/ +@Component({ + selector: 'ds-themed-browse-by-metadata-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +@rendersBrowseBy(BrowseByDataType.Metadata) +export class ThemedBrowseByMetadataPageComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'BrowseByMetadataPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-metadata-page.component`); + } +} diff --git a/src/app/browse-by/browse-by-routing.module.ts b/src/app/browse-by/browse-by-routing.module.ts index 72d78f13fd..5788d3cc70 100644 --- a/src/app/browse-by/browse-by-routing.module.ts +++ b/src/app/browse-by/browse-by-routing.module.ts @@ -17,7 +17,7 @@ import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-bro component: ThemedBrowseBySwitcherComponent, canActivate: [BrowseByGuard], resolve: { breadcrumb: BrowseByI18nBreadcrumbResolver }, - data: { title: 'browse.title', breadcrumbKey: 'browse.metadata' } + data: { title: 'browse.title.page', breadcrumbKey: 'browse.metadata' } } ] }]) diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index 1ebaa7face..ceb4c6a6c6 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -1,6 +1,10 @@ import { hasNoValue } from '../../shared/empty.util'; import { InjectionToken } from '@angular/core'; import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { + DEFAULT_THEME, + resolveTheme +} from '../../shared/object-collection/shared/listable-object/listable-object.decorator'; export enum BrowseByDataType { Title = 'title', @@ -10,7 +14,7 @@ export enum BrowseByDataType { export const DEFAULT_BROWSE_BY_TYPE = BrowseByDataType.Metadata; -export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor>('getComponentByBrowseByType', { +export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType, theme) => GenericConstructor>('getComponentByBrowseByType', { providedIn: 'root', factory: () => getComponentByBrowseByType }); @@ -20,13 +24,17 @@ const map = new Map(); /** * Decorator used for rendering Browse-By pages by type * @param browseByType The type of page + * @param theme The optional theme for the component */ -export function rendersBrowseBy(browseByType: BrowseByDataType) { +export function rendersBrowseBy(browseByType: BrowseByDataType, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(map.get(browseByType))) { - map.set(browseByType, component); + map.set(browseByType, new Map()); + } + if (hasNoValue(map.get(browseByType).get(theme))) { + map.get(browseByType).set(theme, component); } else { - throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}"`); + throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}" and theme "${theme}"`); } }; } @@ -34,11 +42,16 @@ export function rendersBrowseBy(browseByType: BrowseByDataType) { /** * Get the component used for rendering a Browse-By page by type * @param browseByType The type of page + * @param theme the theme to match */ -export function getComponentByBrowseByType(browseByType) { - const comp = map.get(browseByType); +export function getComponentByBrowseByType(browseByType, theme) { + let themeMap = map.get(browseByType); + if (hasNoValue(themeMap)) { + themeMap = map.get(DEFAULT_BROWSE_BY_TYPE); + } + const comp = resolveTheme(themeMap, theme); if (hasNoValue(comp)) { - map.get(DEFAULT_BROWSE_BY_TYPE); + return themeMap.get(DEFAULT_THEME); } return comp; } diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index cb82ddb7c4..c2e1c9cb68 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -4,7 +4,8 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; import { BrowseDefinition } from '../../core/shared/browse-definition.model'; -import { BehaviorSubject, of as observableOf } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; +import { ThemeService } from '../../shared/theme-support/theme.service'; describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; @@ -44,11 +45,20 @@ describe('BrowseBySwitcherComponent', () => { data }; + let themeService: ThemeService; + let themeName: string; + beforeEach(waitForAsync(() => { + themeName = 'dspace'; + themeService = jasmine.createSpyObj('themeService', { + getThemeName: themeName, + }); + TestBed.configureTestingModule({ declarations: [BrowseBySwitcherComponent], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ThemeService, useValue: themeService }, { provide: BROWSE_BY_COMPONENT_FACTORY, useValue: jasmine.createSpy('getComponentByBrowseByType').and.returnValue(null) } ], schemas: [NO_ERRORS_SCHEMA] @@ -68,7 +78,7 @@ describe('BrowseBySwitcherComponent', () => { }); it(`should call getComponentByBrowseByType with type "${type.dataType}"`, () => { - expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType); + expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType, themeName); }); }); }); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index cf4c1d9856..0d3a35bebf 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -5,6 +5,7 @@ import { map } from 'rxjs/operators'; import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { ThemeService } from '../../shared/theme-support/theme.service'; @Component({ selector: 'ds-browse-by-switcher', @@ -21,7 +22,8 @@ export class BrowseBySwitcherComponent implements OnInit { browseByComponent: Observable; public constructor(protected route: ActivatedRoute, - @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType) => GenericConstructor) { + protected themeService: ThemeService, + @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { } /** @@ -29,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit { */ ngOnInit(): void { this.browseByComponent = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType)) + map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType, this.themeService.getThemeName())) ); } diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts index 584da1c45a..e32c0ac430 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts @@ -18,11 +18,11 @@ import { BrowseService } from '../../core/browse/browse.service'; import { RouterMock } from '../../shared/mocks/router.mock'; import { VarDirective } from '../../shared/utils/var.directive'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; + describe('BrowseByTitlePageComponent', () => { let comp: BrowseByTitlePageComponent; @@ -77,7 +77,8 @@ describe('BrowseByTitlePageComponent', () => { { provide: BrowseService, useValue: mockBrowseService }, { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: PaginationService, useValue: paginationService }, - { provide: Router, useValue: new RouterMock() } + { provide: Router, useValue: new RouterMock() }, + { provide: APP_CONFIG, useValue: environment } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts index b2798b7fa8..5320d7bb48 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts @@ -1,19 +1,18 @@ import { combineLatest as observableCombineLatest } from 'rxjs'; -import { Component } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { hasValue } from '../../shared/empty.util'; import { BrowseByMetadataPageComponent, - browseParamsToOptions + browseParamsToOptions, getBrowseSearchOptions } from '../browse-by-metadata-page/browse-by-metadata-page.component'; -import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { BrowseService } from '../../core/browse/browse.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; @Component({ selector: 'ds-browse-by-title-page', @@ -23,20 +22,21 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c /** * Component for browsing items by title (dc.title) */ -@rendersBrowseBy(BrowseByDataType.Title) export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, protected dsoService: DSpaceObjectDataService, protected paginationService: PaginationService, - protected router: Router) { - super(route, browseService, dsoService, paginationService, router); + protected router: Router, + @Inject(APP_CONFIG) public appConfig: AppConfig) { + super(route, browseService, dsoService, paginationService, router, appConfig); } ngOnInit(): void { const sortConfig = new SortOptions('dc.title', SortDirection.ASC); - this.updatePage(new BrowseEntrySearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig)); + // include the thumbnail configuration in browse search options + this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails)); this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( @@ -45,8 +45,9 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; - this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId), undefined, undefined); + this.startsWith = +params.startsWith || params.startsWith; + this.browseId = params.id || this.defaultBrowseId; + this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); this.updateParent(params.scope); })); this.updateStartsWithTextOptions(); diff --git a/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts new file mode 100644 index 0000000000..4a1bcc0bc1 --- /dev/null +++ b/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; +import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; + +/** + * Themed wrapper for BrowseByTitlePageComponent + */ +@Component({ + selector: 'ds-themed-browse-by-title-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +@rendersBrowseBy(BrowseByDataType.Title) +export class ThemedBrowseByTitlePageComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'BrowseByTitlePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-title-page/browse-by-title-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-title-page.component`); + } +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index e1dfaacea5..14e21f8b4c 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -7,12 +7,20 @@ import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date- import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; import { ComcolModule } from '../shared/comcol/comcol.module'; +import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; +import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component'; +import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator BrowseByTitlePageComponent, BrowseByMetadataPageComponent, - BrowseByDatePageComponent + BrowseByDatePageComponent, + + ThemedBrowseByMetadataPageComponent, + ThemedBrowseByDatePageComponent, + ThemedBrowseByTitlePageComponent, + ]; @NgModule({ diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index bb84153835..23698de84e 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -16,7 +16,7 @@ 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 { EntityTypeService } from '../../core/data/entity-type.service'; +import { EntityTypeDataService } from '../../core/data/entity-type-data.service'; import { ItemType } from '../../core/shared/item-relationships/item-type.model'; import { MetadataValue } from '../../core/shared/metadata.models'; import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators'; @@ -61,7 +61,7 @@ export class CollectionFormComponent extends ComColFormComponent imp protected dsoService: CommunityDataService, protected requestService: RequestService, protected objectCache: ObjectCacheService, - protected entityTypeService: EntityTypeService) { + protected entityTypeService: EntityTypeDataService) { super(formService, translate, notificationsService, authService, requestService, objectCache); } diff --git a/src/app/collection-page/collection-form/collection-form.models.ts b/src/app/collection-page/collection-form/collection-form.models.ts index 37e9d8a9a0..1a009d95a1 100644 --- a/src/app/collection-page/collection-form/collection-form.models.ts +++ b/src/app/collection-page/collection-form/collection-form.models.ts @@ -1,5 +1,6 @@ import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; import { DynamicSelectModelConfig } from '@ng-dynamic-forms/core/lib/model/select/dynamic-select.model'; +import { environment } from '../../../environments/environment'; export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig = { id: 'entityType', @@ -26,21 +27,26 @@ export const collectionFormModels: DynamicFormControlModel[] = [ new DynamicTextAreaModel({ id: 'description', name: 'dc.description', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'abstract', name: 'dc.description.abstract', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'rights', name: 'dc.rights', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'tableofcontents', name: 'dc.description.tableofcontents', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'license', name: 'dc.rights.license', + spellCheck: environment.form.spellCheck, }) ]; diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index e8d8d3eb11..db844b588f 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -41,6 +41,12 @@ import { } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; +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 { ConfigurationProperty } from '../../core/shared/configuration-property.model'; describe('CollectionItemMapperComponent', () => { let comp: CollectionItemMapperComponent; @@ -88,7 +94,7 @@ describe('CollectionItemMapperComponent', () => { const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); const itemDataServiceStub = { mapToCollection: () => createSuccessfulRemoteDataObject$({}), - findAllByHref: () => observableOf(emptyList) + findListByHref: () => observableOf(emptyList), }; const activatedRouteStub = { parent: { @@ -110,15 +116,15 @@ describe('CollectionItemMapperComponent', () => { }; const searchServiceStub = Object.assign(new SearchServiceStub(), { search: () => observableOf(emptyList), - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ clearDiscoveryRequests: () => {} - /* tslint:enable:no-empty */ + /* eslint-enable no-empty,@typescript-eslint/no-empty-function */ }); const collectionDataServiceStub = { getMappedItems: () => observableOf(emptyList), - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ clearMappedItemsRequests: () => {} - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ }; const routeServiceStub = { getRouteParameterValue: () => { @@ -141,6 +147,25 @@ describe('CollectionItemMapperComponent', () => { isAuthorized: observableOf(true) }); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], @@ -157,8 +182,19 @@ describe('CollectionItemMapperComponent', () => { { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: RouteService, useValue: routeServiceStub }, - { provide: AuthorizationDataService, useValue: authorizationDataService } + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, ] + }).overrideComponent(CollectionItemMapperComponent, { + set: { + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationServiceStub + } + ] } }).compileComponents(); })); diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts index 3172616efc..7911be8e64 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -143,7 +143,7 @@ export class CollectionItemMapperComponent implements OnInit { if (shouldUpdate === true) { this.shouldUpdate$.next(false); } - return this.itemDataService.findAllByHref(collectionRD.payload._links.mappedItems.href, Object.assign(options, { + return this.itemDataService.findListByHref(collectionRD.payload._links.mappedItems.href, Object.assign(options, { sort: this.defaultSortOptions }),!shouldUpdate, false, followLink('owningCollection')).pipe( getAllSucceededRemoteData() diff --git a/src/app/collection-page/collection-page-routing.module.ts b/src/app/collection-page/collection-page-routing.module.ts index 5879e523af..819ee8ca16 100644 --- a/src/app/collection-page/collection-page-routing.module.ts +++ b/src/app/collection-page/collection-page-routing.module.ts @@ -6,7 +6,7 @@ import { CreateCollectionPageComponent } from './create-collection-page/create-c import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; -import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component'; +import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component'; import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver'; import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; @@ -18,9 +18,9 @@ import { COLLECTION_CREATE_PATH } from './collection-page-routing-paths'; import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard'; -import { MenuItemType } from '../shared/menu/initial-menus-state'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedCollectionPageComponent } from './themed-collection-page.component'; +import { MenuItemType } from '../shared/menu/menu-item-type.model'; @NgModule({ imports: [ @@ -52,7 +52,7 @@ import { ThemedCollectionPageComponent } from './themed-collection-page.componen }, { path: ITEMTEMPLATE_PATH, - component: EditItemTemplatePageComponent, + component: ThemedEditItemTemplatePageComponent, canActivate: [AuthenticatedGuard], resolve: { item: ItemTemplatePageResolver, @@ -72,6 +72,7 @@ import { ThemedCollectionPageComponent } from './themed-collection-page.componen id: 'statistics_collection_:id', active: true, visible: true, + index: 2, model: { type: MenuItemType.LINK, text: 'menu.section.statistics', diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 9d598a3b69..eebfdbd829 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -1,8 +1,8 @@
-
-
+
+
@@ -13,15 +13,14 @@ + [alternateText]="'Collection Logo'"> - - +
-
+
- - +
@@ -57,8 +56,8 @@
- + @@ -75,7 +74,7 @@
- +
diff --git a/src/app/collection-page/collection-page.component.ts b/src/app/collection-page/collection-page.component.ts index be602f8458..f730f44658 100644 --- a/src/app/collection-page/collection-page.component.ts +++ b/src/app/collection-page/collection-page.component.ts @@ -16,7 +16,6 @@ import { Item } from '../core/shared/item.model'; import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteData, - redirectOn4xx, toDSpaceObjectListRD } from '../core/shared/operators'; @@ -28,6 +27,8 @@ import { PaginationService } from '../core/pagination/pagination.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { getCollectionPageRoute } from './collection-page-routing-paths'; +import { redirectOn4xx } from '../core/shared/authorized.operators'; +import { BROWSE_LINKS_TO_FOLLOW } from '../core/browse/browse.service'; @Component({ selector: 'ds-collection-page', @@ -74,6 +75,7 @@ export class CollectionPageComponent implements OnInit { this.paginationConfig.pageSize = 5; this.paginationConfig.currentPage = 1; this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC); + } ngOnInit(): void { @@ -102,13 +104,14 @@ export class CollectionPageComponent implements OnInit { getFirstSucceededRemoteData(), map((rd) => rd.payload.id), switchMap((id: string) => { - return this.searchService.search( + return this.searchService.search( new PaginatedSearchOptions({ scope: id, pagination: currentPagination, sort: currentSort, dsoTypes: [DSpaceObjectType.ITEM] - })).pipe(toDSpaceObjectListRD()) as Observable>>; + }), null, true, true, ...BROWSE_LINKS_TO_FOLLOW) + .pipe(toDSpaceObjectListRD()) as Observable>>; }), startWith(undefined) // Make sure switching pages shows loading component ) diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index 3652823200..ff49b983ff 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -8,6 +8,7 @@ import { CollectionPageRoutingModule } from './collection-page-routing.module'; import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component'; +import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component'; import { EditItemPageModule } from '../item-page/edit-item-page/edit-item-page.module'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; import { SearchService } from '../core/shared/search/search.service'; @@ -24,7 +25,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module'; StatisticsModule.forRoot(), EditItemPageModule, CollectionFormModule, - ComcolModule + ComcolModule, ], declarations: [ CollectionPageComponent, @@ -32,11 +33,12 @@ import { ComcolModule } from '../shared/comcol/comcol.module'; CreateCollectionPageComponent, DeleteCollectionPageComponent, EditItemTemplatePageComponent, + ThemedEditItemTemplatePageComponent, CollectionItemMapperComponent ], providers: [ SearchService, - ] + ], }) export class CollectionPageModule { diff --git a/src/app/collection-page/collection-page.resolver.ts b/src/app/collection-page/collection-page.resolver.ts index d476a180d3..2f5b3ed37a 100644 --- a/src/app/collection-page/collection-page.resolver.ts +++ b/src/app/collection-page/collection-page.resolver.ts @@ -17,7 +17,7 @@ export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ followLink('parentCommunity', {}, followLink('parentCommunity') ), - followLink('logo') + followLink('logo'), ]; /** diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html index 4abb149498..c33675a752 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html @@ -5,11 +5,11 @@

{{ 'collection.delete.text' | translate:{ dso: dso.name } }}

-
+
- diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts index 3e30373070..1876936efb 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts @@ -5,7 +5,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; /** * Component that represents the page where a user can delete an existing Collection @@ -24,8 +23,7 @@ export class DeleteCollectionPageComponent extends DeleteComColPageComponent -
+
\ No newline at end of file +
diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index b67ee9a1bd..d7b0d0c475 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -1,5 +1,5 @@
-
+
- +

{{ 'collection.edit.tabs.source.form.head' | translate }}

@@ -43,7 +43,7 @@
-
+
- +
diff --git a/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts new file mode 100644 index 0000000000..b53f4e6c45 --- /dev/null +++ b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; + +@Component({ + selector: 'ds-themed-edit-item-template-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +/** + * Component for editing the item template of a collection + */ +export class ThemedEditItemTemplatePageComponent extends ThemedComponent { + protected getComponentName(): string { + return 'EditItemTemplatePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/collection-page/edit-item-template-page/edit-item-template-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-item-template-page.component'); + } +} diff --git a/src/app/collection-page/themed-collection-page.component.ts b/src/app/collection-page/themed-collection-page.component.ts index 82074e43e6..2faf418423 100644 --- a/src/app/collection-page/themed-collection-page.component.ts +++ b/src/app/collection-page/themed-collection-page.component.ts @@ -6,7 +6,7 @@ import { CollectionPageComponent } from './collection-page.component'; * Themed wrapper for CollectionPageComponent */ @Component({ - selector: 'ds-themed-community-page', + selector: 'ds-themed-collection-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', }) diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 02774b794c..e2a2bb748f 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,9 +1,10 @@ -import { FindListOptions } from '../core/data/request.models'; import { hasValue } from '../shared/empty.util'; -import { CommunityListService, FlatNode } from './community-list-service'; +import { CommunityListService} from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { finalize } from 'rxjs/operators'; +import { FlatNode } from './flat-node.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; /** * DataSource object needed by a CDK Tree to render its nodes. diff --git a/src/app/community-list-page/community-list-page.component.html b/src/app/community-list-page/community-list-page.component.html index 08accdc0e5..9759f4405d 100644 --- a/src/app/community-list-page/community-list-page.component.html +++ b/src/app/community-list-page/community-list-page.component.html @@ -1,4 +1,4 @@

{{ 'communityList.title' | translate }}

- +
diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 3b009e227c..18c28068be 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -5,12 +5,14 @@ import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListComponent } from './community-list/community-list.component'; import { ThemedCommunityListPageComponent } from './themed-community-list-page.component'; +import { ThemedCommunityListComponent } from './community-list/themed-community-list.component'; const DECLARATIONS = [ CommunityListPageComponent, CommunityListComponent, - ThemedCommunityListPageComponent + ThemedCommunityListPageComponent, + ThemedCommunityListComponent ]; /** * The page which houses a title and the community list, as described in community-list.component diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index fe53a98257..410dd9f804 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -7,13 +7,16 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo import { buildPaginatedList } from '../core/data/paginated-list.model'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { StoreMock } from '../shared/testing/store.mock'; -import { CommunityListService, FlatNode, toFlatNode } from './community-list-service'; +import { CommunityListService, toFlatNode } from './community-list-service'; import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityDataService } from '../core/data/community-data.service'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; -import { FindListOptions } from '../core/data/request.models'; import { PageInfo } from '../core/shared/page-info.model'; +import { FlatNode } from './flat-node.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { APP_CONFIG } from 'src/config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; describe('CommunityListService', () => { let store: StoreMock; @@ -190,13 +193,14 @@ describe('CommunityListService', () => { }; TestBed.configureTestingModule({ providers: [CommunityListService, + { provide: APP_CONFIG, useValue: environment }, { provide: CollectionDataService, useValue: collectionDataServiceStub }, { provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: Store, useValue: StoreMock }, ], }); store = TestBed.inject(Store as any); - service = new CommunityListService(communityDataServiceStub, collectionDataServiceStub, store); + service = new CommunityListService(environment, communityDataServiceStub, collectionDataServiceStub, store); }); it('should create', inject([CommunityListService], (serviceIn: CommunityListService) => { diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index 76d33585da..99e9dbeb0d 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@angular/core'; +/* eslint-disable max-classes-per-file */ +import { Inject, Injectable } from '@angular/core'; import { createSelector, Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; @@ -6,45 +7,23 @@ import { filter, map, switchMap } from 'rxjs/operators'; import { AppState } from '../app.reducer'; import { CommunityDataService } from '../core/data/community-data.service'; -import { FindListOptions } from '../core/data/request.models'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; import { PageInfo } from '../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; -import { PaginatedList, buildPaginatedList } from '../core/data/paginated-list.model'; +import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityListSaveAction } from './community-list.actions'; import { CommunityListState } from './community-list.reducer'; import { getCommunityPageRoute } from '../community-page/community-page-routing-paths'; import { getCollectionPageRoute } from '../collection-page/collection-page-routing-paths'; -import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../core/shared/operators'; import { followLink } from '../shared/utils/follow-link-config.model'; - -/** - * Each node in the tree is represented by a flatNode which contains info about the node itself and its position and - * state in the tree. There are nodes representing communities, collections and show more links. - */ -export interface FlatNode { - isExpandable$: Observable; - name: string; - id: string; - level: number; - isExpanded?: boolean; - parent?: FlatNode; - payload: Community | Collection | ShowMoreFlatNode; - isShowMoreNode: boolean; - route?: string; - currentCommunityPage?: number; - currentCollectionPage?: number; -} - -/** - * The show more links in the community tree are also represented by a flatNode so we know where in - * the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) - */ -export class ShowMoreFlatNode { -} +import { FlatNode } from './flat-node.model'; +import { ShowMoreFlatNode } from './show-more-flat-node.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; // Helper method to combine an flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Observable[]): Observable => @@ -102,18 +81,22 @@ const communityListStateSelector = (state: AppState) => state.communityList; const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); -export const MAX_COMCOLS_PER_PAGE = 20; - /** * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource * and connection to the store to retrieve and save the state of the community list */ -// tslint:disable-next-line:max-classes-per-file @Injectable() export class CommunityListService { - constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, - private store: Store) { + private pageSize: number; + + constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, + private communityDataService: CommunityDataService, + private collectionDataService: CollectionDataService, + private store: Store + ) { + this.pageSize = appConfig.communityList.pageSize; } private configOnePage: FindListOptions = Object.assign(new FindListOptions(), { @@ -168,7 +151,7 @@ export class CommunityListService { private getTopCommunities(options: FindListOptions): Observable> { return this.communityDataService.findTop({ currentPage: options.currentPage, - elementsPerPage: MAX_COMCOLS_PER_PAGE, + elementsPerPage: this.pageSize, sort: { field: options.sort.field, direction: options.sort.direction @@ -239,7 +222,7 @@ export class CommunityListService { let subcoms = []; for (let i = 1; i <= currentCommunityPage; i++) { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { - elementsPerPage: MAX_COMCOLS_PER_PAGE, + elementsPerPage: this.pageSize, currentPage: i }, followLink('subcommunities', { findListOptions: this.configOnePage }), @@ -264,7 +247,7 @@ export class CommunityListService { let collections = []; for (let i = 1; i <= currentCollectionPage; i++) { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { - elementsPerPage: MAX_COMCOLS_PER_PAGE, + elementsPerPage: this.pageSize, currentPage: i }) .pipe( diff --git a/src/app/community-list-page/community-list.actions.ts b/src/app/community-list-page/community-list.actions.ts index 1d2f732ac4..8e8d6d87cf 100644 --- a/src/app/community-list-page/community-list.actions.ts +++ b/src/app/community-list-page/community-list.actions.ts @@ -1,6 +1,6 @@ import { Action } from '@ngrx/store'; import { type } from '../shared/ngrx/type'; -import { FlatNode } from './community-list-service'; +import { FlatNode } from './flat-node.model'; /** * All the action types of the community-list diff --git a/src/app/community-list-page/community-list.reducer.ts b/src/app/community-list-page/community-list.reducer.ts index 236201b353..99c8350cf4 100644 --- a/src/app/community-list-page/community-list.reducer.ts +++ b/src/app/community-list-page/community-list.reducer.ts @@ -1,5 +1,5 @@ -import { FlatNode } from './community-list-service'; import { CommunityListActions, CommunityListActionTypes, CommunityListSaveAction } from './community-list.actions'; +import { FlatNode } from './flat-node.model'; /** * States we wish to put in store concerning the community list diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index f441dfa36e..821cb58473 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -1,4 +1,4 @@ - +
@@ -57,7 +57,7 @@ - +
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 1f020b7744..575edf14e8 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,7 +1,7 @@ import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { CommunityListComponent } from './community-list.component'; -import { CommunityListService, FlatNode, showMoreFlatNode, toFlatNode } from '../community-list-service'; +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'; @@ -15,6 +15,7 @@ import { Collection } from '../../core/shared/collection.model'; import { of as observableOf } from 'rxjs'; import { By } from '@angular/platform-browser'; import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { FlatNode } from '../flat-node.model'; describe('CommunityListComponent', () => { let component: CommunityListComponent; 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 49065c5ec5..556387da25 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,11 +1,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; -import { CommunityListService, FlatNode } from '../community-list-service'; +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'; /** * A tree-structured list of nodes representing the communities, their subCommunities and collections. 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 new file mode 100644 index 0000000000..4a986e737c --- /dev/null +++ b/src/app/community-list-page/community-list/themed-community-list.component.ts @@ -0,0 +1,24 @@ +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', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedCommunityListComponent extends ThemedComponent { + protected getComponentName(): string { + return 'CommunityListComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/community-list-page/community-list/community-list.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./community-list.component`); + } + +} diff --git a/src/app/community-list-page/flat-node.model.ts b/src/app/community-list-page/flat-node.model.ts new file mode 100644 index 0000000000..0aabbeb489 --- /dev/null +++ b/src/app/community-list-page/flat-node.model.ts @@ -0,0 +1,22 @@ +import { Observable } from 'rxjs'; +import { Community } from '../core/shared/community.model'; +import { Collection } from '../core/shared/collection.model'; +import { ShowMoreFlatNode } from './show-more-flat-node.model'; + +/** + * Each node in the tree is represented by a flatNode which contains info about the node itself and its position and + * state in the tree. There are nodes representing communities, collections and show more links. + */ +export interface FlatNode { + isExpandable$: Observable; + name: string; + id: string; + level: number; + isExpanded?: boolean; + parent?: FlatNode; + payload: Community | Collection | ShowMoreFlatNode; + isShowMoreNode: boolean; + route?: string; + currentCommunityPage?: number; + currentCollectionPage?: number; +} diff --git a/src/app/community-list-page/show-more-flat-node.model.ts b/src/app/community-list-page/show-more-flat-node.model.ts new file mode 100644 index 0000000000..801c9e7388 --- /dev/null +++ b/src/app/community-list-page/show-more-flat-node.model.ts @@ -0,0 +1,6 @@ +/** + * The show more links in the community tree are also represented by a flatNode so we know where in + * the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) + */ +export class ShowMoreFlatNode { +} 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 a3730fd418..c6dd1147c3 100644 --- a/src/app/community-page/community-form/community-form.component.ts +++ b/src/app/community-page/community-form/community-form.component.ts @@ -13,6 +13,7 @@ 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'; /** * Form used for creating and editing communities @@ -52,18 +53,22 @@ export class CommunityFormComponent extends ComColFormComponent { new DynamicTextAreaModel({ id: 'description', name: 'dc.description', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'abstract', name: 'dc.description.abstract', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'rights', name: 'dc.rights', + spellCheck: environment.form.spellCheck, }), new DynamicTextAreaModel({ id: 'tableofcontents', name: 'dc.description.tableofcontents', + spellCheck: environment.form.spellCheck, }), ]; diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts index ad1b1fd2f2..4870d52dd9 100644 --- a/src/app/community-page/community-page-routing.module.ts +++ b/src/app/community-page/community-page-routing.module.ts @@ -11,9 +11,9 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi 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 { MenuItemType } from '../shared/menu/initial-menus-state'; 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'; @NgModule({ imports: [ @@ -55,6 +55,7 @@ import { ThemedCommunityPageComponent } from './themed-community-page.component' id: 'statistics_community_:id', active: true, visible: true, + index: 2, model: { type: MenuItemType.LINK, text: 'menu.section.statistics', diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index cf7282eb4b..368fec08a5 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -10,8 +10,8 @@ - - + + @@ -20,17 +20,18 @@ [title]="'community.page.news'"> -
+
- - - - - + + + + + +
@@ -41,5 +42,5 @@
- +
diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index 70259a599b..b1a0cfc946 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -13,11 +13,12 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; -import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../core/shared/operators'; +import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; import { AuthService } from '../core/auth/auth.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 { redirectOn4xx } from '../core/shared/authorized.operators'; @Component({ selector: 'ds-community-page', diff --git a/src/app/community-page/community-page.module.ts b/src/app/community-page/community-page.module.ts index 724b762e90..1dd9e82499 100644 --- a/src/app/community-page/community-page.module.ts +++ b/src/app/community-page/community-page.module.ts @@ -13,10 +13,18 @@ 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 './sub-community-list/themed-community-page-sub-community-list.component'; +import { + ThemedCollectionPageSubCollectionListComponent +} from './sub-collection-list/themed-community-page-sub-collection-list.component'; const DECLARATIONS = [CommunityPageComponent, ThemedCommunityPageComponent, + ThemedCommunityPageSubCommunityListComponent, CommunityPageSubCollectionListComponent, + ThemedCollectionPageSubCollectionListComponent, CommunityPageSubCommunityListComponent, CreateCommunityPageComponent, DeleteCommunityPageComponent]; @@ -28,7 +36,7 @@ const DECLARATIONS = [CommunityPageComponent, CommunityPageRoutingModule, StatisticsModule.forRoot(), CommunityFormModule, - ComcolModule + ComcolModule, ], declarations: [ ...DECLARATIONS 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 658f3da436..751d001c51 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 @@ -5,11 +5,11 @@

{{ 'community.delete.text' | translate:{ dso: dso.name } }}

-
+
- 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 0cccc503e1..6e640c64be 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 @@ -5,7 +5,6 @@ import { ActivatedRoute, Router } from '@angular/router'; 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 { RequestService } from '../../core/data/request.service'; /** * Component that represents the page where a user can delete an existing Community @@ -24,9 +23,8 @@ export class DeleteCommunityPageComponent extends DeleteComColPageComponent diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts index d1188df02d..9dddb87f8d 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts @@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ComcolModule } from '../../../shared/comcol/comcol.module'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; describe('CommunityRolesComponent', () => { @@ -64,6 +66,7 @@ describe('CommunityRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, + { provide: NotificationsService, useClass: NotificationsServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -75,8 +78,9 @@ describe('CommunityRolesComponent', () => { fixture.detectChanges(); }); - it('should display a community admin role component', () => { + it('should display a community admin role component', (done) => { expect(de.query(By.css('ds-comcol-role .community-admin'))) .toBeTruthy(); + done(); }); }); 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 3bb2de9a62..9468aa7048 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 @@ -19,28 +19,14 @@ export class CommunityRolesComponent implements OnInit { dsoRD$: Observable>; /** - * The community to manage, as an observable. + * The different roles for the community, as an observable. */ - get community$(): Observable { - return this.dsoRD$.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ); - } + comcolRoles$: Observable; /** - * The different roles for the community. + * The community to manage, as an observable. */ - getComcolRoles$(): Observable { - return this.community$.pipe( - map((community) => [ - { - name: 'community-admin', - href: community._links.adminGroup.href, - }, - ]), - ); - } + community$: Observable; constructor( protected route: ActivatedRoute, @@ -52,5 +38,22 @@ export class CommunityRolesComponent implements OnInit { first(), map((data) => data.dso), ); + + this.community$ = this.dsoRD$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ); + + /** + * The different roles for the community. + */ + this.comcolRoles$ = this.community$.pipe( + map((community) => [ + { + name: 'community-admin', + href: community._links.adminGroup.href, + }, + ]), + ); } } 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 index 2b0fc73f2a..0479ea6bc6 100644 --- 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 @@ -21,7 +21,7 @@ import { ComcolModule } from '../../shared/comcol/comcol.module'; EditCommunityPageRoutingModule, CommunityFormModule, ComcolModule, - ResourcePoliciesModule + ResourcePoliciesModule, ], declarations: [ EditCommunityPageComponent, diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html index 9928ebd18a..69f16ee3ac 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html @@ -9,5 +9,5 @@
- + diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts index 93a6c6fbb1..bca3c42a95 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -11,20 +11,24 @@ import { CommunityPageSubCollectionListComponent } from './community-page-sub-co import { Community } from '../../core/shared/community.model'; import { SharedModule } from '../../shared/shared.module'; import { CollectionDataService } from '../../core/data/collection-data.service'; -import { FindListOptions } from '../../core/data/request.models'; 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { of as observableOf } from 'rxjs'; 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 { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; describe('CommunityPageSubCollectionList Component', () => { let comp: CommunityPageSubCollectionListComponent; @@ -122,6 +126,25 @@ describe('CommunityPageSubCollectionList Component', () => { themeService = getMockThemeService(); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -138,6 +161,10 @@ describe('CommunityPageSubCollectionList Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -149,17 +176,20 @@ describe('CommunityPageSubCollectionList Component', () => { comp.community = mockCommunity; }); - it('should display a list of collections', () => { - subCollList = collections; - 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'); + it('should display a list of collections', () => { + waitForAsync(() => { + subCollList = collections; + 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'); + }); }); it('should not display the header when list of collections is empty', () => { diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts index adb4c32a32..181ee7cd6f 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; @@ -12,6 +12,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options 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'; @Component({ selector: 'ds-community-page-sub-collection-list', @@ -19,9 +20,15 @@ import { switchMap } from 'rxjs/operators'; templateUrl: './community-page-sub-collection-list.component.html', animations:[fadeIn] }) -export class CommunityPageSubCollectionListComponent implements OnInit { +export class CommunityPageSubCollectionListComponent implements OnInit, OnDestroy { @Input() community: Community; + /** + * Optional page size. Overrides communityList.pageSize configuration for this component. + * Value can be added in the themed version of the parent component. + */ + @Input() pageSize: number; + /** * The pagination configuration */ @@ -50,7 +57,9 @@ export class CommunityPageSubCollectionListComponent implements OnInit { ngOnInit(): void { this.config = new PaginationComponentOptions(); this.config.id = this.pageId; - this.config.pageSize = 5; + if (hasValue(this.pageSize)) { + this.config.pageSize = this.pageSize; + } this.config.currentPage = 1; this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); this.initPage(); diff --git a/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts b/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts new file mode 100644 index 0000000000..f1f49f204c --- /dev/null +++ b/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts @@ -0,0 +1,28 @@ +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', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedCollectionPageSubCollectionListComponent extends ThemedComponent { + @Input() community: Community; + @Input() pageSize: number; + protected inAndOutputNames: (keyof CommunityPageSubCollectionListComponent & keyof this)[] = ['community', 'pageSize']; + + protected getComponentName(): string { + return 'CommunityPageSubCollectionListComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/community-page/sub-collection-list/community-page-sub-collection-list.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./community-page-sub-collection-list.component`); + } + +} diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html index 2d14dce60a..be2788a9f4 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html @@ -9,5 +9,5 @@
- + diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index e573259b63..0a14fe6dd1 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -13,18 +13,22 @@ 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 { FindListOptions } from '../../core/data/request.models'; 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { of as observableOf } from 'rxjs'; 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 { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; describe('CommunityPageSubCommunityListComponent Component', () => { let comp: CommunityPageSubCommunityListComponent; @@ -119,6 +123,25 @@ describe('CommunityPageSubCommunityListComponent Component', () => { } }; + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + const paginationService = new PaginationServiceStub(); themeService = getMockThemeService(); @@ -139,6 +162,10 @@ describe('CommunityPageSubCommunityListComponent Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -151,17 +178,20 @@ describe('CommunityPageSubCommunityListComponent Component', () => { }); - it('should display a list of sub-communities', () => { - subCommList = subcommunities; - 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'); + it('should display a list of sub-communities', () => { + waitForAsync(() => { + subCommList = subcommunities; + 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'); + }); }); it('should not display the header when list of sub-communities is empty', () => { diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts index 2c30ede554..e9da10bfa1 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; @@ -12,6 +12,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { takeUntilCompletedRemoteData } from '../../core/shared/operators'; import { switchMap } from 'rxjs/operators'; import { PaginationService } from '../../core/pagination/pagination.service'; +import { hasValue } from '../../shared/empty.util'; @Component({ selector: 'ds-community-page-sub-community-list', @@ -22,9 +23,15 @@ import { PaginationService } from '../../core/pagination/pagination.service'; /** * Component to render the sub-communities of a Community */ -export class CommunityPageSubCommunityListComponent implements OnInit { +export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy { @Input() community: Community; + /** + * Optional page size. Overrides communityList.pageSize configuration for this component. + * Value can be added in the themed version of the parent component. + */ + @Input() pageSize: number; + /** * The pagination configuration */ @@ -53,7 +60,9 @@ export class CommunityPageSubCommunityListComponent implements OnInit { ngOnInit(): void { this.config = new PaginationComponentOptions(); this.config.id = this.pageId; - this.config.pageSize = 5; + if (hasValue(this.pageSize)) { + this.config.pageSize = this.pageSize; + } this.config.currentPage = 1; this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); this.initPage(); diff --git a/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts b/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts new file mode 100644 index 0000000000..852c53186e --- /dev/null +++ b/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts @@ -0,0 +1,29 @@ +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', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponent { + + @Input() community: Community; + @Input() pageSize: number; + protected inAndOutputNames: (keyof CommunityPageSubCommunityListComponent & keyof this)[] = ['community', 'pageSize']; + + protected getComponentName(): string { + return 'CommunityPageSubCommunityListComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/community-page/sub-community-list/community-page-sub-community-list.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./community-page-sub-community-list.component`); + } + +} diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts index 707daf9e30..704922c5b5 100644 --- a/src/app/core/auth/auth-request.service.spec.ts +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -7,57 +7,154 @@ import { TestScheduler } from 'rxjs/testing'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { ShortLivedToken } from './models/short-lived-token.model'; 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 { RestRequestMethod } from '../data/rest-request-method'; describe(`AuthRequestService`, () => { let halService: HALEndpointService; let endpointURL: string; + let requestID: string; let shortLivedToken: ShortLivedToken; let shortLivedTokenRD: RemoteData; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let service: AuthRequestService; + let service; let testScheduler; - class TestAuthRequestService extends AuthRequestService { - constructor( - hes: HALEndpointService, - rs: RequestService, - rdbs: RemoteDataBuildService - ) { - super(hes, rs, rdbs); - } + const status = new AuthStatus(); - protected createShortLivedTokenRequest(href: string): PostRequest { - return new PostRequest(this.requestService.generateRequestId(), href); - } + class TestAuthRequestService extends AuthRequestService { + constructor( + hes: HALEndpointService, + rs: RequestService, + rdbs: RemoteDataBuildService + ) { + super(hes, rs, rdbs); } - const init = (cold: typeof TestScheduler.prototype.createColdObservable) => { - endpointURL = 'https://rest.api/auth'; - shortLivedToken = Object.assign(new ShortLivedToken(), { - value: 'some-token' - }); - shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); + protected createShortLivedTokenRequest(href: string): PostRequest { + return new PostRequest(this.requestService.generateRequestId(), href); + } + } - halService = jasmine.createSpyObj('halService', { - 'getEndpoint': cold('a', { a: endpointURL }) - }); - requestService = jasmine.createSpyObj('requestService', { - 'send': null - }); - rdbService = jasmine.createSpyObj('rdbService', { - 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) - }); + const init = (cold: typeof TestScheduler.prototype.createColdObservable) => { + endpointURL = 'https://rest.api/auth'; + requestID = 'requestID'; + shortLivedToken = Object.assign(new ShortLivedToken(), { + value: 'some-token' + }); + shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); - service = new TestAuthRequestService(halService, requestService, rdbService); - }; + halService = jasmine.createSpyObj('halService', { + 'getEndpoint': cold('a', { a: endpointURL }) + }); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': requestID, + 'send': null, + }); + rdbService = jasmine.createSpyObj('rdbService', { + 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) + }); + + service = new TestAuthRequestService(halService, requestService, rdbService); + + spyOn(service as any, 'fetchRequest').and.returnValue(cold('a', { a: createSuccessfulRemoteDataObject(status) })); + }; + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe('REST request methods', () => { + let options: HttpOptions; beforeEach(() => { - testScheduler = new TestScheduler((actual, expected) => { - expect(actual).toEqual(expected); + options = Object.create({}); + }); + + describe('GET', () => { + it('should send a GET request to the right endpoint and return the auth status', () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + init(cold); + + expectObservable(service.getRequest('method', options)).toBe('a', { + a: objectContaining({ payload: status }), + }); + flush(); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + uuid: requestID, + href: endpointURL + '/method', + method: RestRequestMethod.GET, + body: undefined, + options, + })); + expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID); + }); + }); + + it('should send the request even if caller doesn\'t subscribe to the response', () => { + testScheduler.run(({ cold, flush }) => { + init(cold); + + service.getRequest('method', options); + flush(); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + uuid: requestID, + href: endpointURL + '/method', + method: RestRequestMethod.GET, + body: undefined, + options, + })); + }); }); }); + describe('POST', () => { + it('should send a POST request to the right endpoint and return the auth status', () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + init(cold); + + expectObservable(service.postToEndpoint('method', { content: 'something' }, options)).toBe('a', { + a: objectContaining({ payload: status }), + }); + flush(); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + uuid: requestID, + href: endpointURL + '/method', + method: RestRequestMethod.POST, + body: { content: 'something' }, + options, + })); + expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID); + }); + }); + + it('should send the request even if caller doesn\'t subscribe to the response', () => { + testScheduler.run(({ cold, flush }) => { + init(cold); + + service.postToEndpoint('method', { content: 'something' }, options); + flush(); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + uuid: requestID, + href: endpointURL + '/method', + method: RestRequestMethod.POST, + body: { content: 'something' }, + options, + })); + }); + }); + }); + }); + describe(`getShortlivedToken`, () => { it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => { testScheduler.run(({ cold, expectObservable, flush }) => { diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 00a94822d3..5c0c3340c7 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,9 +1,9 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap, tap, take } from 'rxjs/operators'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { isNotEmpty } from '../../shared/empty.util'; -import { GetRequest, PostRequest, RestRequest, } from '../data/request.models'; +import { GetRequest, PostRequest, } from '../data/request.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -11,13 +11,14 @@ import { RemoteData } from '../data/remote-data'; 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 */ export abstract class AuthRequestService { protected linkName = 'authn'; - protected browseEndpoint = ''; protected shortlivedtokensEndpoint = 'shortlivedtokens'; constructor(protected halService: HALEndpointService, @@ -26,38 +27,78 @@ export abstract class AuthRequestService { ) { } - protected fetchRequest(request: RestRequest): Observable> { - return this.rdbService.buildFromRequestUUID(request.uuid).pipe( + /** + * Fetch the response to a request from the cache, once it's completed. + * @param requestId the UUID of the request for which to retrieve the response + * @protected + */ + protected fetchRequest(requestId: string, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.rdbService.buildFromRequestUUID(requestId, ...linksToFollow).pipe( getFirstCompletedRemoteData(), ); } - protected getEndpointByMethod(endpoint: string, method: string): string { - return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; + protected getEndpointByMethod(endpoint: string, method: string, ...linksToFollow: FollowLinkConfig[]): string { + let url = isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; + if (linksToFollow?.length > 0) { + linksToFollow.forEach((link: FollowLinkConfig, index: number) => { + url += ((index === 0) ? '?' : '&') + `embed=${link.name}`; + }); + } + + return url; } + /** + * Send a POST request to an authentication endpoint + * @param method the method to send to (e.g. 'status') + * @param body the data to send (optional) + * @param options the HTTP options for the request + */ public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable> { - return this.halService.getEndpoint(this.linkName).pipe( + const requestId = this.requestService.generateRequestId(); + + const endpoint$ = this.halService.getEndpoint(this.linkName); + + endpoint$.pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), - map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, body, options)), - tap((request: PostRequest) => this.requestService.send(request)), - mergeMap((request: PostRequest) => this.fetchRequest(request)), - distinctUntilChanged()); + map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)), + take(1) + ).subscribe((request: PostRequest) => { + this.requestService.send(request); + }); + + return endpoint$.pipe( + switchMap(() => this.fetchRequest(requestId)), + ); } - public getRequest(method: string, options?: HttpOptions): Observable> { - return this.halService.getEndpoint(this.linkName).pipe( + /** + * Send a GET request to an authentication endpoint + * @param method the method to send to (e.g. 'status') + * @param options the HTTP options for the request + */ + public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + const endpoint$ = this.halService.getEndpoint(this.linkName); + + endpoint$.pipe( filter((href: string) => isNotEmpty(href)), - map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), + map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), distinctUntilChanged(), - map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), - tap((request: GetRequest) => this.requestService.send(request)), - mergeMap((request: GetRequest) => this.fetchRequest(request)), - distinctUntilChanged()); - } + map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)), + take(1) + ).subscribe((request: GetRequest) => { + this.requestService.send(request); + }); + return endpoint$.pipe( + switchMap(() => this.fetchRequest(requestId, ...linksToFollow)), + ); + } /** * Factory function to create the request object to send. This needs to be a POST client side and * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 15e42c8576..60440d371e 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ // import @ngrx import { Action } from '@ngrx/store'; // import type function @@ -39,7 +40,6 @@ export const AuthActionTypes = { UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE') }; -/* tslint:disable:max-classes-per-file */ /** * Authenticate. @@ -411,7 +411,6 @@ export class SetUserAsIdleAction implements Action { export class UnsetUserAsIdleAction implements Action { public type: string = AuthActionTypes.UNSET_USER_AS_IDLE; } -/* tslint:enable:max-classes-per-file */ /** * Actions type. diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index ed91eb3eea..f09db04d99 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -1,4 +1,4 @@ -import { fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Store, StoreModule } from '@ngrx/store'; @@ -219,6 +219,9 @@ describe('AuthEffects', () => { const expected = cold('--b-', { b: new RetrieveTokenAction() }); expect(authEffects.checkTokenCookie$).toBeObservable(expected); + authEffects.checkTokenCookie$.subscribe(() => { + expect((authEffects as any).authorizationsService.invalidateAuthorizationsRequestCache).toHaveBeenCalled(); + }); }); it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { @@ -393,44 +396,43 @@ describe('AuthEffects', () => { }); describe('when auth loaded is false', () => { - it('should not call removeToken method', (done) => { + it('should not call removeToken method', fakeAsync(() => { store.overrideSelector(isAuthenticatedLoaded, false); - actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + actions = observableOf({ type: StoreActionTypes.REHYDRATE }); spyOn(authServiceStub, 'removeToken'); authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => { - expect(authServiceStub.removeToken).not.toHaveBeenCalled(); - + expect(false).toBeTrue(); // subscribe to trigger taps, fail if the effect emits (we don't expect it to) }); - - done(); - }); + tick(1000); + expect(authServiceStub.removeToken).not.toHaveBeenCalled(); + })); }); describe('when auth loaded is true', () => { - it('should call removeToken method', fakeAsync(() => { + it('should call removeToken method', (done) => { + spyOn(console, 'log').and.callThrough(); + store.overrideSelector(isAuthenticatedLoaded, true); - actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + actions = observableOf({ type: StoreActionTypes.REHYDRATE }); spyOn(authServiceStub, 'removeToken'); authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => { expect(authServiceStub.removeToken).toHaveBeenCalled(); - flush(); + done(); }); - - })); + }); }); }); describe('invalidateAuthorizationsRequestCache$', () => { it('should call invalidateAuthorizationsRequestCache method in response to a REHYDRATE action', (done) => { - actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + actions = observableOf({ type: StoreActionTypes.REHYDRATE }); authEffects.invalidateAuthorizationsRequestCache$.subscribe(() => { expect((authEffects as any).authorizationsService.invalidateAuthorizationsRequestCache).toHaveBeenCalled(); + done(); }); - - done(); }); }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 1477a1832e..22d1bf35e7 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -10,7 +10,7 @@ import { } from 'rxjs'; import { catchError, filter, map, observeOn, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; // import services @@ -67,8 +67,7 @@ export class AuthEffects { * Authenticate user. * @method authenticate */ - @Effect() - public authenticate$: Observable = this.actions$.pipe( + public authenticate$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE), switchMap((action: AuthenticateAction) => { return this.authService.authenticate(action.payload.email, action.payload.password).pipe( @@ -77,26 +76,23 @@ export class AuthEffects { catchError((error) => observableOf(new AuthenticationErrorAction(error))) ); }) - ); + )); - @Effect() - public authenticateSuccess$: Observable = this.actions$.pipe( + public authenticateSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) - ); + )); - @Effect() - public authenticated$: Observable = this.actions$.pipe( + public authenticated$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED), 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))),); }) - ); + )); - @Effect() - public authenticatedSuccess$: Observable = this.actions$.pipe( + public authenticatedSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( @@ -110,26 +106,23 @@ export class AuthEffects { return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); } }) - ); + )); - @Effect({ dispatch: false }) - public redirectAfterLoginSuccess$: Observable = this.actions$.pipe( + public redirectAfterLoginSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS), tap((action: RedirectAfterLoginSuccessAction) => { this.authService.clearRedirectUrl(); this.authService.navigateToRedirectUrl(action.payload); }) - ); + ), { dispatch: false }); // It means "reacts to this action but don't send another" - @Effect({ dispatch: false }) - public authenticatedError$: Observable = this.actions$.pipe( + public authenticatedError$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_ERROR), tap((action: LogOutSuccessAction) => this.authService.removeToken()) - ); + ), { dispatch: false }); - @Effect() - public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe( + public retrieveAuthenticatedEperson$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON), switchMap((action: RetrieveAuthenticatedEpersonAction) => { const impersonatedUserID = this.authService.getImpersonateID(); @@ -143,25 +136,24 @@ export class AuthEffects { map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); }) - ); + )); - @Effect() - public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), + 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())) ); }) - ); + )); - @Effect() - public checkTokenCookie$: Observable = this.actions$.pipe( + public checkTokenCookie$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE), switchMap(() => { return this.authService.checkAuthenticationCookie().pipe( map((response: AuthStatus) => { if (response.authenticated) { + this.authorizationsService.invalidateAuthorizationsRequestCache(); return new RetrieveTokenAction(); } else { return new RetrieveAuthMethodsAction(response); @@ -170,10 +162,9 @@ export class AuthEffects { catchError((error) => observableOf(new AuthenticatedErrorAction(error))) ); }) - ); + )); - @Effect() - public retrieveToken$: Observable = this.actions$.pipe( + public retrieveToken$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_TOKEN), switchMap((action: AuthenticateAction) => { return this.authService.refreshAuthenticationToken(null).pipe( @@ -182,55 +173,51 @@ export class AuthEffects { catchError((error) => observableOf(new AuthenticationErrorAction(error))) ); }) - ); + )); - @Effect() - public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), + 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())) ); }) - ); + )); // It means "reacts to this action but don't send another" - @Effect({ dispatch: false }) - public refreshTokenSuccess$: Observable = this.actions$.pipe( + public refreshTokenSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) - ); + ), { dispatch: false }); /** * When the store is rehydrated in the browser, * clear a possible invalid token or authentication errors */ - @Effect({ dispatch: false }) - public clearInvalidTokenOnRehydrate$: Observable = this.actions$.pipe( + public clearInvalidTokenOnRehydrate$: Observable = createEffect(() => this.actions$.pipe( ofType(StoreActionTypes.REHYDRATE), switchMap(() => { const isLoaded$ = this.store.pipe(select(isAuthenticatedLoaded)); const authenticated$ = this.store.pipe(select(isAuthenticated)); - return observableCombineLatest(isLoaded$, authenticated$).pipe( + return observableCombineLatest([isLoaded$, authenticated$]).pipe( take(1), filter(([loaded, authenticated]) => loaded && !authenticated), tap(() => this.authService.removeToken()), tap(() => this.authService.resetAuthenticationError()) ); - })); + })), { dispatch: false }); /** * When the store is rehydrated in the browser, invalidate all cache hits regarding the * authorizations endpoint, to be sure to have consistent responses after a login with external idp * */ - @Effect({ dispatch: false }) invalidateAuthorizationsRequestCache$ = this.actions$ + invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()) - ); + ), { dispatch: false }); - @Effect() - public logOut$: Observable = this.actions$ + public logOut$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.LOG_OUT), switchMap(() => { @@ -240,26 +227,23 @@ export class AuthEffects { catchError((error) => observableOf(new LogOutErrorAction(error))) ); }) - ); + )); - @Effect({ dispatch: false }) - public logOutSuccess$: Observable = this.actions$ + public logOutSuccess$: Observable = createEffect(() => this.actions$ .pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS), tap(() => this.authService.removeToken()), tap(() => this.authService.clearRedirectUrl()), tap(() => this.authService.refreshAfterLogout()) - ); + ), { dispatch: false }); - @Effect({ dispatch: false }) - public redirectToLoginTokenExpired$: Observable = this.actions$ + public redirectToLoginTokenExpired$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED), tap(() => this.authService.removeToken()), tap(() => this.authService.redirectToLoginWhenTokenExpired()) - ); + ), { dispatch: false }); - @Effect() - public retrieveMethods$: Observable = this.actions$ + public retrieveMethods$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), switchMap((action: RetrieveAuthMethodsAction) => { @@ -269,7 +253,7 @@ export class AuthEffects { catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) ); }) - ); + )); /** * For any action that is not in {@link IDLE_TIMER_IGNORE_TYPES} that comes in => Start the idleness timer @@ -277,8 +261,7 @@ export class AuthEffects { * => Return the action to set the user as idle ({@link SetUserAsIdleAction}) * @method trackIdleness */ - @Effect() - public trackIdleness$: Observable = this.actions$.pipe( + public trackIdleness$: Observable = createEffect(() => this.actions$.pipe( filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), // Using switchMap the effect will stop subscribing to the previous timer if a new action comes // in, and start a new timer @@ -289,7 +272,7 @@ export class AuthEffects { // Re-enter the zone to dispatch the action observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), map(() => new SetUserAsIdleAction()), - ); + )); /** * @constructor diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 029deb5326..04bbc4acaf 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -20,9 +20,9 @@ describe(`AuthInterceptor`, () => { const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ select: observableOf(true) }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index a49030110b..e55d0c0ff9 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -144,7 +144,7 @@ export class AuthInterceptor implements HttpInterceptor { const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g; const realms = completeWWWauthenticateHeader.match(regex); - // tslint:disable-next-line:forin + // eslint-disable-next-line guard-for-in for (const j in realms) { const splittedRealm = realms[j].split(', '); diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 8cd587b61a..8ebc9f6cb0 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -192,7 +192,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, idle: false }; @@ -212,7 +212,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, idle: false }; @@ -558,7 +558,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, authMethods: [], idle: false diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 2fc79a8861..acdb8ef812 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -17,6 +17,7 @@ import { 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'; /** * The auth state. @@ -92,11 +93,15 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED: + return Object.assign({}, state, { + loading: true, + blocking: true + }); + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { loading: true, - blocking: true }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -210,7 +215,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.RETRIEVE_AUTH_METHODS: return Object.assign({}, state, { loading: true, - blocking: true }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: @@ -248,6 +252,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut idle: false, }); + case StoreActionTypes.REHYDRATE: + return Object.assign({}, state, { + blocking: true, + }); + default: return state; } diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index ced8bb94c8..b38d17aecd 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -32,6 +32,8 @@ 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'; describe('AuthService test', () => { @@ -56,6 +58,13 @@ describe('AuthService test', () => { let linkService; let hardRedirectService; + const AuthStatusWithSpecialGroups = Object.assign(new AuthStatus(), { + uuid: 'test', + authenticated: true, + okay: true, + specialGroups: SpecialGroupDataMock$ + }); + function init() { mockStore = jasmine.createSpyObj('store', { dispatch: {}, @@ -368,25 +377,25 @@ describe('AuthService test', () => { it('should redirect to reload with redirect url', () => { authService.navigateToRedirectUrl('/collection/123'); // Reload with redirect URL set to /collection/123 - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); it('should redirect to reload with /home', () => { authService.navigateToRedirectUrl('/home'); // Reload with redirect URL set to /home - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); it('should redirect to regular reload and not to /login', () => { authService.navigateToRedirectUrl('/login'); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$'))); }); it('should redirect to regular reload when no redirect url is found', () => { authService.navigateToRedirectUrl(undefined); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { @@ -511,6 +520,19 @@ describe('AuthService test', () => { expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); }); }); + + describe('getSpecialGroupsFromAuthStatus', () => { + beforeEach(() => { + spyOn(authRequest, 'getRequest').and.returnValue(createSuccessfulRemoteDataObject$(AuthStatusWithSpecialGroups)); + }); + + it('should call navigateToRedirectUrl with no url', () => { + const expectRes = cold('(a|)', { + a: SpecialGroupDataMock + }); + expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes); + }); + }); }); describe('when user is not logged in', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 5738948ebd..3034c00197 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; -import { map, startWith, switchMap, take } from 'rxjs/operators'; +import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -44,13 +44,18 @@ import { import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload } from '../shared/operators'; +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'; @@ -88,6 +93,8 @@ export class AuthService { 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) ).subscribe((authenticated: boolean) => this._authenticated = authenticated); @@ -112,7 +119,7 @@ export class AuthService { if (hasValue(rd.payload) && rd.payload.authenticated) { return rd.payload; } else { - throw(new Error('Invalid email or password')); + throw (new Error('Invalid email or password')); } })); @@ -166,7 +173,7 @@ export class AuthService { if (hasValue(status) && status.authenticated) { return status._links.eperson.href; } else { - throw(new Error('Not authenticated')); + throw (new Error('Not authenticated')); } })); } @@ -211,6 +218,22 @@ export class AuthService { this.store.dispatch(new CheckAuthenticationTokenAction()); } + /** + * Return the special groups list embedded in the AuthStatus model + */ + public getSpecialGroupsFromAuthStatus(): Observable>> { + return this.authRequestService.getRequest('status', null, followLink('specialGroups')).pipe( + getFirstCompletedRemoteData(), + switchMap((status: RemoteData) => { + if (status.hasSucceeded) { + return status.payload.specialGroups; + } else { + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); + } + }) + ); + } + /** * Checks if token is present into storage and is not expired */ @@ -249,7 +272,7 @@ export class AuthService { if (hasValue(status) && status.authenticated) { return status.token; } else { - throw(new Error('Not authenticated')); + throw (new Error('Not authenticated')); } })); } @@ -288,7 +311,7 @@ export class AuthService { if (hasValue(status) && !status.authenticated) { return true; } else { - throw(new Error('auth.errors.invalid-user')); + throw (new Error('auth.errors.invalid-user')); } })); } @@ -325,7 +348,7 @@ export class AuthService { let token: AuthTokenInfo; let currentlyRefreshingToken = false; this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { - // If new token is undefined an it wasn't previously => Refresh failed + // If new token is undefined and it wasn't previously => Refresh failed if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); @@ -447,8 +470,8 @@ export class AuthService { */ public navigateToRedirectUrl(redirectUrl: string) { // Don't do redirect if already on reload url - if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) { - let url = `/reload/${new Date().getTime()}`; + if (!hasValue(redirectUrl) || !redirectUrl.includes('reload/')) { + let url = `reload/${new Date().getTime()}`; if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { url += `?redirect=${encodeURIComponent(redirectUrl)}`; } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 0b9eeec509..1ab1d2e0a5 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -11,9 +11,9 @@ import { Observable } from 'rxjs'; import { map, find, switchMap } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { isAuthenticated, isAuthenticationLoading } from './selectors'; import { AuthService, LOGIN_ROUTE } from './auth.service'; +import { CoreState } from '../core-state.model'; /** * Prevent unauthorized activating and loading of routes diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index 197c025407..d18b1ccf9a 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -2,10 +2,11 @@ import { autoserialize, deserialize, deserializeAs } from 'cerialize'; import { Observable } from 'rxjs'; import { link, typedObject } from '../../cache/builders/build-decorators'; import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; -import { CacheableObject } from '../../cache/object-cache.reducer'; import { RemoteData } from '../../data/remote-data'; import { EPerson } from '../../eperson/models/eperson.model'; import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { Group } from '../../eperson/models/group.model'; +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'; @@ -13,6 +14,8 @@ 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 @@ -61,6 +64,7 @@ export class AuthStatus implements CacheableObject { _links: { self: HALLink; eperson: HALLink; + specialGroups: HALLink; }; /** @@ -70,6 +74,13 @@ export class AuthStatus implements CacheableObject { @link(EPERSON) eperson?: Observable>; + /** + * The SpecialGroup of this auth status + * Will be undefined unless the SpecialGroup {@link HALLink} has been resolved. + */ + @link(GROUP, true) + specialGroups?: Observable>>; + /** * True if the token is valid, false if there was no token or the token wasn't valid */ diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 9d999c4c3f..594d6d8b39 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -4,5 +4,6 @@ export enum AuthMethodType { Ldap = 'ldap', Ip = 'ip', X509 = 'x509', - Oidc = 'oidc' + Oidc = 'oidc', + Orcid = 'orcid' } diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index 5a362e8606..0579ae0cd1 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -34,6 +34,11 @@ export class AuthMethod { this.location = location; break; } + case 'orcid': { + this.authMethodType = AuthMethodType.Orcid; + this.location = location; + break; + } default: { break; 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 118c724328..3786bd8e6a 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,10 @@ -import { CacheableObject } from '../../cache/object-cache.reducer'; 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'; /** * 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 9ee9f7eb2e..ce8d38d6ba 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -7,7 +7,8 @@ import { createSelector } from '@ngrx/store'; * notation packages up all of the exports into a single object. */ import { AuthState } from './auth.reducer'; -import { AppState } from '../../app.reducer'; +import { CoreState } from '../core-state.model'; +import { coreSelector } from '../core.selectors'; /** * Returns the user state. @@ -15,7 +16,7 @@ import { AppState } from '../../app.reducer'; * @param {AppState} state Top level state. * @return {AuthState} */ -export const getAuthState = (state: any) => state.core.auth; +export const getAuthState = createSelector(coreSelector, (state: CoreState) => state.auth); /** * Returns true if the user is authenticated. diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index ea5a3b41f2..fc8ab18bfb 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -36,7 +36,7 @@ export class ServerAuthService extends AuthService { if (hasValue(status) && status.authenticated) { return status._links.eperson.href; } else { - throw(new Error('Not authenticated')); + throw (new Error('Not authenticated')); } })); } diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts index d39b3cc33d..1ba7a16b14 100644 --- a/src/app/core/auth/token-response-parsing.service.ts +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -1,9 +1,9 @@ import { ResponseParsingService } from '../data/parsing.service'; -import { RestRequest } from '../data/request.models'; 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() /** diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts new file mode 100644 index 0000000000..b2ddade682 --- /dev/null +++ b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; + +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 { BitstreamBreadcrumbsService } from './bitstream-breadcrumbs.service'; + +/** + * The class 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); + } + + /** + * 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 new file mode 100644 index 0000000000..333886ed3d --- /dev/null +++ b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; + +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 { BitstreamDataService } from '../data/bitstream-data.service'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; +import { Item } from '../shared/item.model'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; + +/** + * Service to calculate DSpaceObject breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root' +}) +export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { + constructor( + protected bitstreamService: BitstreamDataService, + protected linkService: LinkService, + protected dsoNameService: DSONameService + ) { + super(linkService, dsoNameService); + } + + /** + * Method to recursively calculate the breadcrumbs + * This method returns the name and url of the key and all its parent DSOs recursively, top down + * @param key The key (a DSpaceObject) used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: ChildHALResource & DSpaceObject, url: string): Observable { + const label = this.dsoNameService.getName(key); + const crumb = new Breadcrumb(label, url); + + return this.getOwningItem(key.uuid).pipe( + switchMap((parentRD: RemoteData) => { + if (isNotEmpty(parentRD) && hasValue(parentRD.payload)) { + const parent = parentRD.payload; + return super.getBreadcrumbs(parent, getDSORoute(parent)); + } + return observableOf([]); + + }), + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + ); + } + + getOwningItem(uuid: string): Observable> { + return this.bitstreamService.findById(uuid, true, true, ...BITSTREAM_PAGE_LINKS_TO_FOLLOW).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + switchMap((bitstream: Bitstream) => { + if (hasValue(bitstream)) { + return bitstream.bundle.pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + switchMap((bundle: Bundle) => { + if (hasValue(bundle)) { + return bundle.item.pipe( + getFirstCompletedRemoteData(), + ); + } else { + return observableOf(undefined); + } + }) + ); + } else { + return observableOf(undefined); + } + }) + ); + } +} diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 650bbd3301..8be4e5e099 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -2,23 +2,26 @@ import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DataService } from '../data/data.service'; -import { getRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; import { map } from 'rxjs/operators'; 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 { hasValue } from '../../shared/empty.util'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export abstract class DSOBreadcrumbResolver implements Resolve> { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService) { + protected constructor( + protected breadcrumbService: DSOBreadcrumbsService, + protected dataService: IdentifiableDataService, + ) { } /** @@ -36,7 +39,7 @@ export abstract class DSOBreadcrumbResolver { constructor( - private linkService: LinkService, - private dsoNameService: DSONameService + protected linkService: LinkService, + protected dsoNameService: DSONameService ) { } diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 7a399ce748..9f2f76599a 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -78,15 +78,32 @@ describe(`DSONameService`, () => { }); describe(`factories.Person`, () => { - beforeEach(() => { - spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', ')); + describe(`with person.familyName and person.givenName`, () => { + beforeEach(() => { + spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', ')); + }); + + it(`should return 'person.familyName, person.givenName'`, () => { + const result = (service as any).factories.Person(mockPerson); + expect(result).toBe(mockPersonName); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title'); + }); }); - it(`should return 'person.familyName, person.givenName'`, () => { - const result = (service as any).factories.Person(mockPerson); - expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + describe(`without person.familyName and person.givenName`, () => { + beforeEach(() => { + spyOn(mockPerson, 'firstMetadataValue').and.returnValues(undefined, undefined, mockPersonName); + }); + + it(`should return dc.title`, () => { + const result = (service as any).factories.Person(mockPerson); + expect(result).toBe(mockPersonName); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + }); }); }); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 38363d1989..d56f4a00eb 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; @@ -27,7 +27,15 @@ export class DSONameService { */ private readonly factories = { Person: (dso: DSpaceObject): string => { - return `${dso.firstMetadataValue('person.familyName')}, ${dso.firstMetadataValue('person.givenName')}`; + const familyName = dso.firstMetadataValue('person.familyName'); + const givenName = dso.firstMetadataValue('person.givenName'); + if (isEmpty(familyName) && isEmpty(givenName)) { + return dso.firstMetadataValue('dc.title') || dso.name; + } else if (isEmpty(familyName) || isEmpty(givenName)) { + return familyName || givenName; + } else { + return `${familyName}, ${givenName}`; + } }, OrgUnit: (dso: DSpaceObject): string => { return dso.firstMetadataValue('organization.legalName'); @@ -49,11 +57,14 @@ export class DSONameService { .filter((type) => typeof type === 'string') .find((type: string) => Object.keys(this.factories).includes(type)) as string; + let name; if (hasValue(match)) { - return this.factories[match](dso); - } else { - return this.factories.Default(dso); + name = this.factories[match](dso); } + if (isEmpty(name)) { + name = this.factories.Default(dso); + } + return name; } } 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 d6770f80c0..9377dc715f 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -1,19 +1,13 @@ import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { FindListOptions } from '../data/request.models'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { EMPTY } from 'rxjs'; +import { FindListOptions } from '../data/find-list-options.model'; describe(`BrowseDefinitionDataService`, () => { let service: BrowseDefinitionDataService; - const dataServiceImplSpy = jasmine.createSpyObj('dataService', { + const findAllDataSpy = jasmine.createSpyObj('findAllData', { findAll: EMPTY, - findByHref: EMPTY, - findAllByHref: EMPTY, - findById: EMPTY, }); - const hrefAll = 'https://rest.api/server/api/discover/browses'; - const hrefSingle = 'https://rest.api/server/api/discover/browses/author'; - const id = 'author'; const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), @@ -21,35 +15,14 @@ describe(`BrowseDefinitionDataService`, () => { ]; beforeEach(() => { - service = new BrowseDefinitionDataService(null, null, null, null, null, null, null, null); - (service as any).dataService = dataServiceImplSpy; + service = new BrowseDefinitionDataService(null, null, null, null); + (service as any).findAllData = findAllDataSpy; }); describe(`findAll`, () => { - it(`should call findAll on DataServiceImpl`, () => { + it(`should call findAll on findAllData`, () => { service.findAll(options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); - }); - }); - - describe(`findByHref`, () => { - it(`should call findByHref on DataServiceImpl`, () => { - service.findByHref(hrefSingle, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findByHref).toHaveBeenCalledWith(hrefSingle, true, false, ...linksToFollow); - }); - }); - - describe(`findAllByHref`, () => { - it(`should call findAllByHref on DataServiceImpl`, () => { - service.findAllByHref(hrefAll, options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(hrefAll, options, true, false, ...linksToFollow); - }); - }); - - describe(`findById`, () => { - it(`should call findById on DataServiceImpl`, () => { - service.findAllByHref(id, options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(id, options, true, false, ...linksToFollow); + expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); }); }); }); diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index dd66d8fa53..32c3b44e14 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,126 +1,56 @@ import { Injectable } from '@angular/core'; -import { dataService } from '../cache/builders/build-decorators'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { DataService } from '../data/data.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; -import { FindListOptions } from '../data/request.models'; 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'; -/* tslint:disable:max-classes-per-file */ - -class DataServiceImpl extends DataService { - protected linkPath = 'browses'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} - +/** + * Data service responsible for retrieving browse definitions from the REST server + */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(BROWSE_DEFINITION) -export class BrowseDefinitionDataService { - /** - * A private DataService instance to delegate specific methods to. - */ - private dataService: DataServiceImpl; +export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData { + private findAllData: FindAllDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super('browses', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded * info should be added to the objects * - * @param options Find list options object + * @param options Find list options object * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's * no valid cached version. Defaults to true * @param reRequestOnStale Whether or not the request should automatically be re- * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved - * @return {Observable>>} + * @return {Observable>>} * Return an observable that emits object list */ findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns an observable of {@link RemoteData} of an {@link BrowseDefinition}, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link BrowseDefinition} - * @param href The url of {@link BrowseDefinition} we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of {@link BrowseDefinition}s, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link BrowseDefinition} - * @param href The url of object we want to retrieve - * @param findListOptions Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of - * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param id ID of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/browse/browse-entry-search-options.model.ts b/src/app/core/browse/browse-entry-search-options.model.ts index 417bf7ce75..1604c693e9 100644 --- a/src/app/core/browse/browse-entry-search-options.model.ts +++ b/src/app/core/browse/browse-entry-search-options.model.ts @@ -6,13 +6,16 @@ import { SortOptions } from '../cache/models/sort-options.model'; * - metadataDefinition: The metadata definition to fetch entries or items for * - pagination: Optional pagination options to use * - sort: Optional sorting options to use + * - startsWith An optional value to use to filter the browse results * - scope: An optional scope to limit the results within a specific collection or community + * - fetchThumbnail An optional boolean to request thumbnail for items */ export class BrowseEntrySearchOptions { constructor(public metadataDefinition: string, public pagination?: PaginationComponentOptions, public sort?: SortOptions, public startsWith?: string, - public scope?: string) { + public scope?: string, + public fetchThumbnail?: boolean) { } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index ac68fadb31..46ac8c44f4 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -5,7 +5,6 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-bu 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 { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; @@ -13,6 +12,7 @@ import { BrowseService } from './browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { RequestEntry } from '../data/request-entry.model'; describe('BrowseService', () => { let scheduler: TestScheduler; @@ -139,13 +139,13 @@ describe('BrowseService', () => { }); describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { const expected = browseDefinitions[1]._links.entries.href; scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expected })); }); @@ -153,20 +153,20 @@ describe('BrowseService', () => { }); describe('when findList is called with a valid browse definition id', () => { - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName); scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, undefined, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expected })); }); }); describe('when getBrowseItemsFor is called with a valid filter value and authority key', () => { - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName) + '&filterAuthority=' + encodeURIComponent(mockAuthorityKey); @@ -174,7 +174,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, mockAuthorityKey, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expected })); }); @@ -267,11 +267,11 @@ describe('BrowseService', () => { describe('when getFirstItemFor is called with a valid browse definition id', () => { const expectedURL = browseDefinitions[1]._links.items.href + '?page=0&size=1'; - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expectedURL })); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 05e625d6c1..2fab189254 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -21,6 +21,12 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseDefinitionDataService } from './browse-definition-data.service'; import { HrefOnlyDataService } from '../data/href-only-data.service'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; + + +export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('thumbnail') +]; /** * The service handling all browse requests @@ -96,7 +102,10 @@ export class BrowseService { return href; }) ); - return this.hrefOnlyDataService.findAllByHref(href$); + if (options.fetchThumbnail ) { + return this.hrefOnlyDataService.findListByHref(href$, {}, null, null, ...BROWSE_LINKS_TO_FOLLOW); + } + return this.hrefOnlyDataService.findListByHref(href$); } /** @@ -141,7 +150,10 @@ export class BrowseService { return href; }), ); - return this.hrefOnlyDataService.findAllByHref(href$); + if (options.fetchThumbnail) { + return this.hrefOnlyDataService.findListByHref(href$, {}, null, null, ...BROWSE_LINKS_TO_FOLLOW); + } + return this.hrefOnlyDataService.findListByHref(href$); } /** @@ -172,7 +184,7 @@ export class BrowseService { }) ); - return this.hrefOnlyDataService.findAllByHref(href$).pipe( + return this.hrefOnlyDataService.findListByHref(href$).pipe( getFirstSucceededRemoteData(), getFirstOccurrence() ); @@ -184,7 +196,7 @@ export class BrowseService { * @param items */ getPrevBrowseItems(items: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(items.payload.prev); + return this.hrefOnlyDataService.findListByHref(items.payload.prev); } /** @@ -192,7 +204,7 @@ export class BrowseService { * @param items */ getNextBrowseItems(items: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(items.payload.next); + return this.hrefOnlyDataService.findListByHref(items.payload.next); } /** @@ -200,7 +212,7 @@ export class BrowseService { * @param entries */ getPrevBrowseEntries(entries: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(entries.payload.prev); + return this.hrefOnlyDataService.findListByHref(entries.payload.prev); } /** @@ -208,7 +220,7 @@ export class BrowseService { * @param entries */ getNextBrowseEntries(entries: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(entries.payload.next); + return this.hrefOnlyDataService.findListByHref(entries.payload.next); } /** diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 0c6074630b..150a07f006 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,14 +1,7 @@ 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'; - -/* tslint:disable:max-classes-per-file */ -class TestService { -} - -class AnotherTestService { -} +import { getLinkDefinition, link } from './build-decorators'; class TestHALResource implements HALResource { _links: { @@ -25,31 +18,6 @@ describe('build decorators', () => { beforeEach(() => { testType = new ResourceType('testType-' + new Date().getTime()); }); - describe('@dataService/getDataServiceFor', () => { - - 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(); - }); - }); - - }); describe(`@link/getLinkDefinitions`, () => { it(`should register a link`, () => { @@ -80,4 +48,3 @@ describe('build decorators', () => { }); }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index b561ababde..9e5ebaed85 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -3,20 +3,13 @@ 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 { - CacheableObject, - TypedObject, - getResourceTypeValueFor -} from '../object-cache.reducer'; +import { getResourceTypeValueFor } from '../object-cache.reducer'; import { InjectionToken } from '@angular/core'; +import { TypedObject } from '../typed-object.model'; -export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { - providedIn: 'root', - factory: () => getDataServiceFor -}); export const LINK_DEFINITION_FACTORY = new InjectionToken<(source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition>('getLinkDefinition', { providedIn: 'root', - factory: () => getLinkDefinition + factory: () => getLinkDefinition, }); export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', { providedIn: 'root', @@ -27,7 +20,6 @@ const resolvedLinkKey = Symbol('resolvedLink'); const resolvedLinkMap = new Map(); const typeMap = new Map(); -const dataServiceMap = new Map(); const linkMap = new Map(); /** @@ -46,39 +38,6 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(getResourceTypeValueFor(type)); } -/** - * A class decorator to indicate that this class is a dataservice - * for a given resource type. - * - * "dataservice" in this context means that it has findByHref and - * findAllByHref methods. - * - * @param resourceType the resource type the class is a dataservice for - */ -export function dataService(resourceType: ResourceType): any { - return (target: any) => { - if (hasNoValue(resourceType)) { - throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); - } - const existingDataservice = dataServiceMap.get(resourceType.value); - - if (hasValue(existingDataservice)) { - throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); - } - - dataServiceMap.set(resourceType.value, target); - }; -} - -/** - * Return the dataservice matching the given resource type - * - * @param resourceType the resource type you want the matching dataservice for - */ -export function getDataServiceFor(resourceType: ResourceType) { - return dataServiceMap.get(resourceType.value); -} - /** * A class to represent the data that can be set by the @link decorator */ diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index f567c39314..0ddfe05870 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -1,18 +1,19 @@ +/* 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 { FindListOptions } from '../../data/request.models'; import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import { LinkService } from './link.service'; -import { DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; +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; -/* tslint:disable:max-classes-per-file */ class TestModel implements HALResource { static type = TEST_MODEL; @@ -32,8 +33,8 @@ class TestModel implements HALResource { @Injectable() class TestDataService { - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { - return 'findAllByHref'; + findListByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { + return 'findListByHref'; } findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { @@ -64,7 +65,7 @@ describe('LinkService', () => { } }); testDataService = new TestDataService(); - spyOn(testDataService, 'findAllByHref').and.callThrough(); + spyOn(testDataService, 'findListByHref').and.callThrough(); spyOn(testDataService, 'findByHref').and.callThrough(); TestBed.configureTestingModule({ providers: [LinkService, { @@ -118,8 +119,8 @@ describe('LinkService', () => { }); service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); }); - it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { - expect(testDataService.findAllByHref).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', () => { + expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); }); }); describe('either way', () => { @@ -251,4 +252,3 @@ describe('LinkService', () => { }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index af616332c0..b7af54abf7 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -3,28 +3,30 @@ import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; +import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; import { - DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY, - LinkDefinition + 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' + providedIn: 'root', }) export class LinkService { constructor( protected parentInjector: Injector, - @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor, + @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>, @Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: (source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition, @Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: (source: GenericConstructor) => Map>, ) { @@ -51,7 +53,7 @@ export class LinkService { * @param model the {@link HALResource} to resolve the link for * @param linkToFollow the {@link FollowLinkConfig} to resolve */ - public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable> { + public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable>> { const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); if (hasValue(matchingLinkDef)) { @@ -61,9 +63,9 @@ export class LinkService { throw new Error(`The @link() for ${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 = Injector.create({ + const service: HALDataService = Injector.create({ providers: [], - parent: this.parentInjector + parent: this.parentInjector, }).get(provider); const link = model._links[matchingLinkDef.linkName]; @@ -72,7 +74,7 @@ export class LinkService { try { if (matchingLinkDef.isList) { - return service.findAllByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + return service.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); } else { return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); } 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 0cb45733a6..d9b856bb77 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,4 +1,4 @@ -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +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'; @@ -13,10 +13,14 @@ 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 { RequestEntry, RequestEntryState } from '../../data/request.reducer'; 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 { cold } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; @@ -645,4 +649,211 @@ describe('RemoteDataBuildService', () => { }); }); }); + + describe('buildFromHref', () => { + beforeEach(() => { + (objectCache.getRequestUUIDBySelfLink as jasmine.Spy).and.returnValue(cold('a', { a: 'request/uuid' })); + }); + + describe('when both getRequestFromRequestHref and getRequestFromRequestUUID emit nothing', () => { + beforeEach(() => { + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: undefined })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: undefined })); + }); + + it('should not emit anything', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('')); + }); + }); + + describe('when one of getRequestFromRequestHref or getRequestFromRequestUUID emits nothing', () => { + let requestEntry: RequestEntry; + + beforeEach(() => { + requestEntry = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: undefined })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the existing request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, undefined, RequestEntryState.Success, undefined, {}, undefined), + })); + }); + }); + + describe('when one of getRequestFromRequestHref or getRequestFromRequestUUID is stale', () => { + let requestEntry1: RequestEntry; + let requestEntry2: RequestEntry; + + beforeEach(() => { + requestEntry1 = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + }); + requestEntry2 = Object.assign(new RequestEntry(), { + state: RequestEntryState.SuccessStale, + request: {}, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry1 })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry2 })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the non-stale request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, undefined, RequestEntryState.Success, undefined, {}, undefined), + })); + }); + }); + + describe('when both getRequestFromRequestHref and getRequestFromRequestUUID are stale', () => { + let requestEntry1: RequestEntry; + let requestEntry2: RequestEntry; + + beforeEach(() => { + requestEntry1 = Object.assign(new RequestEntry(), { + state: RequestEntryState.SuccessStale, + request: {}, + lastUpdated: 20, + }); + requestEntry2 = Object.assign(new RequestEntry(), { + state: RequestEntryState.SuccessStale, + request: {}, + lastUpdated: 10, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry1 })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry2 })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the most up-to-date request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, 20, RequestEntryState.SuccessStale, undefined, {}, undefined), + })); + }); + }); + + describe('when both getRequestFromRequestHref and getRequestFromRequestUUID are not stale', () => { + let requestEntry1: RequestEntry; + let requestEntry2: RequestEntry; + + beforeEach(() => { + requestEntry1 = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + lastUpdated: 25, + }); + requestEntry2 = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + lastUpdated: 5, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry1 })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry2 })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the most up-to-date request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, 25, RequestEntryState.Success, undefined, {}, undefined), + })); + }); + }); + }); + + describe('buildFromRequestUUIDAndAwait', () => { + let testScheduler; + + let callback: jasmine.Spy; + let buildFromRequestUUIDSpy; + + const BOOLEAN = { t: true, f: false }; + + const MOCK_PENDING_RD = createPendingRemoteDataObject(); + const MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + const MOCK_FAILED_RD = createFailedRemoteDataObject('failed'); + + const RDs = { + p: MOCK_PENDING_RD, + s: MOCK_SUCCEEDED_RD, + f: MOCK_FAILED_RD, + }; + + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + callback = jasmine.createSpy('callback'); + callback.and.returnValue(observableOf(undefined)); + buildFromRequestUUIDSpy = spyOn(service, 'buildFromRequestUUID').and.callThrough(); + }); + + it('should patch through href & followLinks to buildFromRequestUUID', () => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + service.buildFromRequestUUIDAndAwait('some-href', callback, ...linksToFollow); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith('some-href', ...linksToFollow); + }); + + it('should trigger the callback on successful RD', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.buildFromRequestUUIDAndAwait('some-href', callback).subscribe(rd => { + expect(rd).toBe(MOCK_SUCCEEDED_RD); + expect(callback).toHaveBeenCalled(); + done(); + }); + }); + + it('should trigger the callback on successful RD even if nothing subscribes to the returned Observable', fakeAsync(() => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.buildFromRequestUUIDAndAwait('some-href', callback); + tick(); + + expect(callback).toHaveBeenCalled(); + })); + + it('should not trigger the callback on pending RD', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_PENDING_RD)); + + service.buildFromRequestUUIDAndAwait('some-href', callback).subscribe(rd => { + expect(rd).toBe(MOCK_PENDING_RD); + expect(callback).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should not trigger the callback on failed RD', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD)); + + service.buildFromRequestUUIDAndAwait('some-href', callback).subscribe(rd => { + expect(rd).toBe(MOCK_FAILED_RD); + expect(callback).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should only emit after the callback is done', () => { + testScheduler.run(({ cold: tsCold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + tsCold('-p----s', RDs) + ); + callback.and.returnValue( + tsCold(' --t', BOOLEAN) + ); + + const done$ = service.buildFromRequestUUIDAndAwait('some-href', callback); + expectObservable(done$).toBe( + ' -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 6b67549f2d..76529891d4 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,24 +1,17 @@ import { Injectable } from '@angular/core'; import { + AsyncSubject, combineLatest as observableCombineLatest, Observable, of as observableOf, - race as observableRace } from 'rxjs'; -import { map, switchMap, filter, distinctUntilKeyChanged } from 'rxjs/operators'; +import { map, switchMap, filter, distinctUntilKeyChanged, startWith } from 'rxjs/operators'; import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; -import { - RequestEntry, - ResponseState, - RequestEntryState, - hasSucceeded -} from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; -import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/operators'; import { ObjectCacheService } from '../object-cache.service'; import { LinkService } from './link.service'; import { HALLink } from '../../shared/hal-link.model'; @@ -28,6 +21,11 @@ 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 { ResponseState } from '../../data/response-state.model'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; @Injectable() export class RemoteDataBuildService { @@ -192,6 +190,49 @@ export class RemoteDataBuildService { return this.toRemoteDataObservable(requestEntry$, payload$); } + /** + * Creates a {@link RemoteData} object for a rest request and its response + * and emits it only after the callback function is completed. + * + * @param requestUUID$ The UUID of the request we want to retrieve + * @param callback A function that returns an Observable. It will only be called once the request has succeeded. + * Then, the response will only be emitted after this callback function has emitted. + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + buildFromRequestUUIDAndAwait(requestUUID$: string | Observable, callback: (rd?: RemoteData) => Observable, ...linksToFollow: FollowLinkConfig[]): Observable> { + const response$ = this.buildFromRequestUUID(requestUUID$, ...linksToFollow); + + const callbackDone$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + // if the request succeeded, execute the callback + return callback(rd); + } else { + // otherwise, emit right away so the subscription doesn't stick around + return [true]; + } + }), + ).subscribe(() => { + callbackDone$.next(true); + callbackDone$.complete(); + }); + + return response$.pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + // if the request succeeded, wait for the callback to finish + return callbackDone$.pipe( + map(() => rd), + ); + } else { + return [rd]; + } + }) + ); + } + /** * Creates a {@link RemoteData} object for a rest request and its response * @@ -210,10 +251,27 @@ export class RemoteDataBuildService { this.objectCache.getRequestUUIDBySelfLink(href)), ); - const requestEntry$ = observableRace( - href$.pipe(getRequestFromRequestHref(this.requestService)), - requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), - ).pipe( + const requestEntry$ = observableCombineLatest([ + href$.pipe(getRequestFromRequestHref(this.requestService), startWith(undefined)), + requestUUID$.pipe(getRequestFromRequestUUID(this.requestService), startWith(undefined)), + ]).pipe( + filter(([r1, r2]) => hasValue(r1) || hasValue(r2)), + map(([r1, r2]) => { + // If one of the two requests has no value, return the other (both is impossible due to the filter above) + if (hasNoValue(r2)) { + return r1; + } else if (hasNoValue(r1)) { + return r2; + } + + if ((isStale(r1.state) && isStale(r2.state)) || (!isStale(r1.state) && !isStale(r2.state))) { + // Neither or both are stale, pick the most recent request + return r1.lastUpdated >= r2.lastUpdated ? r1 : r2; + } else { + // One of the two is stale, return the not stale request + return isStale(r2.state) ? r1 : r2; + } + }), distinctUntilKeyChanged('lastUpdated') ); diff --git a/src/app/core/cache/cacheable-object.model.ts b/src/app/core/cache/cacheable-object.model.ts new file mode 100644 index 0000000000..b7d1609d58 --- /dev/null +++ b/src/app/core/cache/cacheable-object.model.ts @@ -0,0 +1,22 @@ +/* tslint:disable:max-classes-per-file */ +import { HALResource } from '../shared/hal-resource.model'; +import { HALLink } from '../shared/hal-link.model'; +import { TypedObject } from './typed-object.model'; + +/** + * An interface to represent objects that can be cached + * + * A cacheable object should have a self link + */ +export class CacheableObject extends TypedObject implements HALResource { + uuid?: string; + handle?: string; + _links: { + self: HALLink; + }; + // isNew: boolean; + // dirtyType: DirtyType; + // hasDirtyAttributes: boolean; + // changedAttributes: AttributeDiffh; + // save(): void; +} diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index ed509341a7..c18a20ffd6 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -1,8 +1,9 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; -import { CacheableObject } from './object-cache.reducer'; import { Operation } from 'fast-json-patch'; +import { CacheableObject } from './cacheable-object.model'; /** * The list of ObjectCacheAction type definitions @@ -12,10 +13,11 @@ export const ObjectCacheActionTypes = { REMOVE: type('dspace/core/cache/object/REMOVE'), RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'), ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), - APPLY_PATCH: type('dspace/core/cache/object/APPLY_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') }; -/* tslint:disable:max-classes-per-file */ /** * An ngrx action to add an object to the cache */ @@ -126,14 +128,55 @@ export class ApplyPatchObjectCacheAction implements Action { } } -/* tslint:enable:max-classes-per-file */ +/** + * An NgRx action to add dependent request UUIDs to a cached object + */ +export class AddDependentsObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD_DEPENDENTS; + payload: { + href: string; + dependentRequestUUIDs: string[]; + }; + + /** + * Create a new AddDependentsObjectCacheAction + * + * @param href the self link of a cached object + * @param dependentRequestUUIDs the UUID of the request that depends on this object + */ + constructor(href: string, dependentRequestUUIDs: string[]) { + this.payload = { + href, + dependentRequestUUIDs, + }; + } +} + +/** + * An NgRx action to remove all dependent request UUIDs from a cached object + */ +export class RemoveDependentsObjectCacheAction implements Action { + type = ObjectCacheActionTypes.REMOVE_DEPENDENTS; + payload: string; + + /** + * Create a new RemoveDependentsObjectCacheAction + * + * @param href the self link of a cached object for which to remove all dependent request UUIDs + */ + constructor(href: string) { + this.payload = href; + } +} /** * A type to encompass all ObjectCacheActions */ export type ObjectCacheAction = AddToObjectCacheAction - | RemoveFromObjectCacheAction - | ResetObjectCacheTimestampsAction - | AddPatchObjectCacheAction - | ApplyPatchObjectCacheAction; + | RemoveFromObjectCacheAction + | ResetObjectCacheTimestampsAction + | AddPatchObjectCacheAction + | ApplyPatchObjectCacheAction + | AddDependentsObjectCacheAction + | RemoveDependentsObjectCacheAction; diff --git a/src/app/core/cache/object-cache.effects.ts b/src/app/core/cache/object-cache.effects.ts index 2bd8ad0e3c..fa2bf6f690 100644 --- a/src/app/core/cache/object-cache.effects.ts +++ b/src/app/core/cache/object-cache.effects.ts @@ -1,6 +1,6 @@ import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { StoreActionTypes } from '../../store.actions'; import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; @@ -16,10 +16,10 @@ export class ObjectCacheEffects { * This assumes that the server cached everything a negligible * time ago, and will likely need to be revisited later */ - @Effect() fixTimestampsOnRehydrate = this.actions$ + fixTimestampsOnRehydrate = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), 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 077a1e67f8..919edc8e57 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -1,12 +1,15 @@ +// 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, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, - ResetObjectCacheTimestampsAction + ResetObjectCacheTimestampsAction, } from './object-cache.actions'; import { objectCacheReducer } from './object-cache.reducer'; @@ -41,21 +44,23 @@ describe('objectCacheReducer', () => { alternativeLinks: [altLink1, altLink2], timeCompleted: new Date().getTime(), msToLive: 900000, - requestUUID: requestUUID1, + requestUUIDs: [requestUUID1], + dependentRequestUUIDs: [], patches: [], isDirty: false, }, [selfLink2]: { data: { type: Item.type, - self: requestUUID2, + self: selfLink2, foo: 'baz', - _links: { self: { href: requestUUID2 } } + _links: { self: { href: selfLink2 } } }, alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), msToLive: 900000, - requestUUID: selfLink2, + requestUUIDs: [requestUUID2], + dependentRequestUUIDs: [requestUUID1], patches: [], isDirty: false } @@ -105,10 +110,10 @@ describe('objectCacheReducer', () => { const action = new AddToObjectCacheAction(objectToCache, timeCompleted, msToLive, requestUUID, altLink1); const newState = objectCacheReducer(testState, action); - /* tslint:disable:no-string-literal */ + /* eslint-disable @typescript-eslint/dot-notation */ expect(newState[selfLink1].data['foo']).toBe('baz'); expect(newState[selfLink1].data['somethingElse']).toBe(true); - /* tslint:enable:no-string-literal */ + /* eslint-enable @typescript-eslint/dot-notation */ }); it('should perform the ADD action without affecting the previous state', () => { @@ -189,4 +194,20 @@ describe('objectCacheReducer', () => { expect((newState[selfLink1].data as any).name).toEqual(newName); }); + it('should add dependent requests on ADD_DEPENDENTS', () => { + let newState = objectCacheReducer(testState, new AddDependentsObjectCacheAction(selfLink1, ['new', 'newer', 'newest'])); + expect(newState[selfLink1].dependentRequestUUIDs).toEqual(['new', 'newer', 'newest']); + + newState = objectCacheReducer(newState, new AddDependentsObjectCacheAction(selfLink2, ['more'])); + expect(newState[selfLink2].dependentRequestUUIDs).toEqual([requestUUID1, 'more']); + }); + + it('should clear dependent requests on REMOVE_DEPENDENTS', () => { + let newState = objectCacheReducer(testState, new RemoveDependentsObjectCacheAction(selfLink1)); + expect(newState[selfLink1].dependentRequestUUIDs).toEqual([]); + + newState = objectCacheReducer(newState, new RemoveDependentsObjectCacheAction(selfLink2)); + expect(newState[selfLink2].dependentRequestUUIDs).toEqual([]); + }); + }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 8c1420704c..dc3f50db68 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,18 +1,18 @@ -import { HALLink } from '../shared/hal-link.model'; -import { HALResource } from '../shared/hal-resource.model'; +/* eslint-disable max-classes-per-file */ import { - ObjectCacheAction, - ObjectCacheActionTypes, + AddDependentsObjectCacheAction, + AddPatchObjectCacheAction, AddToObjectCacheAction, + ApplyPatchObjectCacheAction, + ObjectCacheAction, + ObjectCacheActionTypes, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, - AddPatchObjectCacheAction, - ApplyPatchObjectCacheAction } from './object-cache.actions'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; -import { ResourceType } from '../shared/resource-type'; import { applyPatch, Operation } from 'fast-json-patch'; +import { CacheableObject } from './cacheable-object.model'; /** * An interface to represent a JsonPatch @@ -29,11 +29,6 @@ export interface Patch { operations: Operation[]; } -export abstract class TypedObject { - static type: ResourceType; - type: ResourceType; -} - /** * Get the string value for an object that may be a string or a ResourceType * @@ -49,25 +44,6 @@ export const getResourceTypeValueFor = (type: any): string => { } }; -/* tslint:disable:max-classes-per-file */ -/** - * An interface to represent objects that can be cached - * - * A cacheable object should have a self link - */ -export class CacheableObject extends TypedObject implements HALResource { - uuid?: string; - handle?: string; - _links: { - self: HALLink; - }; - // isNew: boolean; - // dirtyType: DirtyType; - // hasDirtyAttributes: boolean; - // changedAttributes: AttributeDiffh; - // save(): void; -} - /** * An entry in the ObjectCache */ @@ -88,9 +64,17 @@ export class ObjectCacheEntry implements CacheEntry { msToLive: number; /** - * The UUID of the request that caused this entry to be added + * The UUIDs of the requests that caused this entry to be added + * New UUIDs should be added to the front of the array + * to make retrieving the latest UUID easier. */ - requestUUID: string; + requestUUIDs: string[]; + + /** + * A list of UUIDs for the requests that depend on this object. + * When this object is invalidated, these requests will be invalidated as well. + */ + dependentRequestUUIDs: string[]; /** * An array of patches that were made on the client side to this entry, but haven't been sent to the server yet @@ -110,7 +94,6 @@ export class ObjectCacheEntry implements CacheEntry { alternativeLinks: string[]; } -/* tslint:enable:max-classes-per-file */ /** * The ObjectCache State @@ -158,6 +141,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction); } + case ObjectCacheActionTypes.ADD_DEPENDENTS: { + return addDependentsObjectCacheState(state, action as AddDependentsObjectCacheAction); + } + + case ObjectCacheActionTypes.REMOVE_DEPENDENTS: { + return removeDependentsObjectCacheState(state, action as RemoveDependentsObjectCacheAction); + } + default: { return state; } @@ -182,11 +173,12 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio data: action.payload.objectToCache, timeCompleted: action.payload.timeCompleted, msToLive: action.payload.msToLive, - requestUUID: action.payload.requestUUID, + requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], + dependentRequestUUIDs: existing.dependentRequestUUIDs || [], isDirty: isNotEmpty(existing.patches), patches: existing.patches || [], alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] - } + } as ObjectCacheEntry }); } @@ -276,3 +268,49 @@ function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObject } return newState; } + +/** + * Add a list of dependent request UUIDs to a cached object, used when defining new dependencies + * + * @param state the current state + * @param action an AddDependentsObjectCacheAction + * @return the new state, with the dependent requests of the cached object updated + */ +function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDependentsObjectCacheAction): ObjectCacheState { + const href = action.payload.href; + const newState = Object.assign({}, state); + + if (hasValue(newState[href])) { + newState[href] = Object.assign({}, newState[href], { + dependentRequestUUIDs: [ + ...new Set([ + ...newState[href]?.dependentRequestUUIDs || [], + ...action.payload.dependentRequestUUIDs, + ]) + ] + }); + } + + return newState; +} + + +/** + * Remove all dependent request UUIDs from a cached object, used to clear out-of-date depedencies + * + * @param state the current state + * @param action an AddDependentsObjectCacheAction + * @return the new state, with the dependent requests of the cached object updated + */ +function removeDependentsObjectCacheState(state: ObjectCacheState, action: RemoveDependentsObjectCacheAction): ObjectCacheState { + const href = action.payload; + const newState = Object.assign({}, state); + + if (hasValue(newState[href])) { + newState[href] = Object.assign({}, newState[href], { + dependentRequestUUIDs: [] + }); + } + + return newState; +} diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 6863361c34..6af797be29 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -7,23 +7,27 @@ import { Operation } from 'fast-json-patch'; import { empty, of as observableOf } from 'rxjs'; import { first } from 'rxjs/operators'; -import { coreReducers, CoreState } from '../core.reducers'; +import { coreReducers} from '../core.reducers'; import { RestRequestMethod } from '../data/rest-request-method'; import { Item } from '../shared/item.model'; import { + AddDependentsObjectCacheAction, + RemoveDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, - RemoveFromObjectCacheAction + 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 { IndexName } from '../index/index.reducer'; 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; @@ -37,6 +41,7 @@ describe('ObjectCacheService', () => { let altLink1; let altLink2; let requestUUID; + let requestUUID2; let alternativeLink; let timestamp; let timestamp2; @@ -54,6 +59,7 @@ describe('ObjectCacheService', () => { altLink1 = 'https://alternative.link/endpoint/1234'; altLink2 = 'https://alternative.link/endpoint/5678'; requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736'; + requestUUID2 = 'c0f486c1-c4d3-4a03-b293-ca5b71ff0054'; alternativeLink = 'https://rest.api/endpoint/5e4f8a5-be98-4c51-9fd8-6bfedcbd59b7/item'; timestamp = new Date().getTime(); timestamp2 = new Date().getTime() - 200; @@ -70,13 +76,17 @@ describe('ObjectCacheService', () => { data: objectToCache, timeCompleted: timestamp, msToLive: msToLive, - alternativeLinks: [altLink1, altLink2] + alternativeLinks: [altLink1, altLink2], + requestUUIDs: [requestUUID], + dependentRequestUUIDs: [], }; cacheEntry2 = { data: objectToCache, timeCompleted: timestamp2, msToLive: msToLive2, - alternativeLinks: [altLink2] + alternativeLinks: [altLink2], + requestUUIDs: [requestUUID2], + dependentRequestUUIDs: [], }; invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }); operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; @@ -210,25 +220,69 @@ describe('ObjectCacheService', () => { }); }); - describe('has', () => { + describe('hasByHref', () => { + describe('with requestUUID not specified', () => { + describe('getByHref emits an object', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(cacheEntry)); + }); - describe('getByHref emits an object', () => { - beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(observableOf(cacheEntry)); + it('should return true', () => { + expect(service.hasByHref(selfLink)).toBe(true); + }); }); - it('should return true', () => { - expect(service.hasByHref(selfLink)).toBe(true); + describe('getByHref emits nothing', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(empty()); + }); + + it('should return false', () => { + expect(service.hasByHref(selfLink)).toBe(false); + }); }); }); - describe('getByHref emits nothing', () => { - beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(empty()); + describe('with requestUUID specified', () => { + describe('getByHref emits an object that includes the specified requestUUID', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(Object.assign(cacheEntry, { + requestUUIDs: [ + 'something', + 'something-else', + 'specific-request', + ] + }))); + }); + + it('should return true', () => { + expect(service.hasByHref(selfLink, 'specific-request')).toBe(true); + }); }); - it('should return false', () => { - expect(service.hasByHref(selfLink)).toBe(false); + describe('getByHref emits an object that doesn\'t include the specified requestUUID', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(Object.assign(cacheEntry, { + requestUUIDs: [ + 'something', + 'something-else', + ] + }))); + }); + + it('should return true', () => { + expect(service.hasByHref(selfLink, 'specific-request')).toBe(false); + }); + }); + + describe('getByHref emits nothing', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(empty()); + }); + + it('should return false', () => { + expect(service.hasByHref(selfLink, 'specific-request')).toBe(false); + }); }); }); }); @@ -298,4 +352,122 @@ describe('ObjectCacheService', () => { expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink)); }); }); + + describe('request dependencies', () => { + beforeEach(() => { + const state = Object.assign({}, initialState, { + core: Object.assign({}, initialState.core, { + 'cache/object': { + ['objectWithoutDependents']: { + dependentRequestUUIDs: [], + }, + ['objectWithDependents']: { + dependentRequestUUIDs: [requestUUID], + }, + [selfLink]: cacheEntry, + }, + 'index': { + 'object/alt-link-to-self-link': { + [anotherLink]: selfLink, + ['objectWithoutDependentsAlt']: 'objectWithoutDependents', + ['objectWithDependentsAlt']: 'objectWithDependents', + } + } + }) + }); + mockStore.setState(state); + }); + + describe('addDependency', () => { + it('should dispatch an ADD_DEPENDENTS action', () => { + service.addDependency(selfLink, 'objectWithoutDependents'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should resolve alt links', () => { + service.addDependency(anotherLink, 'objectWithoutDependentsAlt'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should not dispatch if either href cannot be resolved to a cached self link', () => { + service.addDependency(selfLink, 'unknown'); + service.addDependency('unknown', 'objectWithoutDependents'); + service.addDependency('nothing', 'matches'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch if either href is undefined', () => { + service.addDependency(selfLink, undefined); + service.addDependency(undefined, 'objectWithoutDependents'); + service.addDependency(undefined, undefined); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch if the dependency exists already', () => { + service.addDependency(selfLink, 'objectWithDependents'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should work with observable hrefs', () => { + service.addDependency(observableOf(selfLink), observableOf('objectWithoutDependents')); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should only dispatch once for the first value of either observable href', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + testScheduler.run(({ cold: tsCold, flush }) => { + const href$ = tsCold('--y-n-n', { + y: selfLink, + n: 'NOPE' + }); + const dependsOnHref$ = tsCold('-y-n-n', { + y: 'objectWithoutDependents', + n: 'NOPE' + }); + + service.addDependency(href$, dependsOnHref$); + flush(); + + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + }); + + it('should not dispatch if either of the hrefs emits undefined', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + testScheduler.run(({ cold: tsCold, flush }) => { + const undefined$ = tsCold('--u'); + + service.addDependency(selfLink, undefined$); + service.addDependency(undefined$, 'objectWithoutDependents'); + service.addDependency(undefined$, undefined$); + flush(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('removeDependents', () => { + it('should dispatch a REMOVE_DEPENDENTS action', () => { + service.removeDependents('objectWithDependents'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents')); + }); + + it('should resolve alt links', () => { + service.removeDependents('objectWithDependentsAlt'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents')); + }); + + it('should not dispatch if the href cannot be resolved to a cached self link', () => { + service.removeDependents('unknown'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 5fec462670..9ca0216210 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -4,29 +4,22 @@ 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 { hasValue, isNotEmpty, isEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core.reducers'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { CoreState } from '../core-state.model'; import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; -import { - selfLinkFromAlternativeLinkSelector, - selfLinkFromUuidSelector -} from '../index/index.selectors'; +import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; -import { - AddPatchObjectCacheAction, - AddToObjectCacheAction, - ApplyPatchObjectCacheAction, - RemoveFromObjectCacheAction -} from './object-cache.actions'; +import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; -import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; +import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { IndexName } from '../index/index.reducer'; import { HALLink } from '../shared/hal-link.model'; +import { CacheableObject } from './cacheable-object.model'; +import { IndexName } from '../index/index-name.model'; /** * The base selector function to select the object cache in the store @@ -196,7 +189,7 @@ export class ObjectCacheService { */ getRequestUUIDBySelfLink(selfLink: string): Observable { return this.getByHref(selfLink).pipe( - map((entry: ObjectCacheEntry) => entry.requestUUID), + map((entry: ObjectCacheEntry) => entry.requestUUIDs[0]), distinctUntilChanged()); } @@ -281,7 +274,7 @@ export class ObjectCacheService { let result = false; this.getByHref(href).subscribe((entry: ObjectCacheEntry) => { if (isNotEmpty(requestUUID)) { - result = entry.requestUUID === requestUUID; + result = entry.requestUUIDs.includes(requestUUID); } else { result = true; } @@ -338,4 +331,97 @@ export class ObjectCacheService { this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); } + /** + * Add a new dependency between two cached objects. + * When {@link dependsOnHref$} is invalidated, {@link href$} will be invalidated as well. + * + * This method should be called _after_ requests have been sent; + * it will only work if both objects are already present in the cache. + * + * If either object is undefined, the dependency will not be added + * + * @param href$ the href of an object to add a dependency to + * @param dependsOnHref$ the href of the new dependency + */ + addDependency(href$: string | Observable, dependsOnHref$: string | Observable) { + if (hasNoValue(href$) || hasNoValue(dependsOnHref$)) { + return; + } + + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + if (typeof dependsOnHref$ === 'string') { + dependsOnHref$ = observableOf(dependsOnHref$); + } + + observableCombineLatest([ + href$, + dependsOnHref$.pipe( + switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)) + ), + ]).pipe( + switchMap(([href, dependsOnSelfLink]: [string, string]) => { + const dependsOnSelfLink$ = observableOf(dependsOnSelfLink); + + return observableCombineLatest([ + dependsOnSelfLink$, + dependsOnSelfLink$.pipe( + switchMap(selfLink => this.getBySelfLink(selfLink)), + map(oce => oce?.dependentRequestUUIDs || []), + ), + this.getByHref(href).pipe( + // only add the latest request to keep dependency index from growing indefinitely + map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]), + ) + ]); + }), + take(1), + ).subscribe(([dependsOnSelfLink, currentDependents, newDependent]: [string, string[], string]) => { + // don't dispatch if either href is invalid or if the new dependency already exists + if (hasValue(dependsOnSelfLink) && hasValue(newDependent) && !currentDependents.includes(newDependent)) { + this.store.dispatch(new AddDependentsObjectCacheAction(dependsOnSelfLink, [newDependent])); + } + }); + } + + /** + * Clear all dependent requests associated with a cache entry. + * + * @href the href of a cached object + */ + removeDependents(href: string) { + this.resolveSelfLink(href).pipe( + take(1), + ).subscribe((selfLink: string) => { + if (hasValue(selfLink)) { + this.store.dispatch(new RemoveDependentsObjectCacheAction(selfLink)); + } + }); + } + + + /** + * Resolve the self link of an existing cached object from an arbitrary href + * + * @param href any href + * @return an observable of the self link corresponding to the given href. + * Will emit the given href if it was a self link, another href + * if the given href was an alt link, or undefined if there is no + * cached object for this href. + */ + private resolveSelfLink(href: string): Observable { + return this.getBySelfLink(href).pipe( + switchMap((oce: ObjectCacheEntry) => { + if (isNotEmpty(oce)) { + return [href]; + } else { + return this.store.pipe( + select(selfLinkFromAlternativeLinkSelector(href)), + ); + } + }), + ); + } + } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3c7c272830..197bf130fb 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,11 +1,11 @@ -import { RequestError } from '../data/request.models'; +/* eslint-disable max-classes-per-file */ import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALLink } from '../shared/hal-link.model'; import { UnCacheableObject } from '../shared/uncacheable-object.model'; +import { RequestError } from '../data/request-error.model'; -/* tslint:disable:max-classes-per-file */ export class RestResponse { public toCache = true; public timeCompleted: number; @@ -140,4 +140,3 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { super(true, statusCode, statusText); } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/server-sync-buffer.actions.ts b/src/app/core/cache/server-sync-buffer.actions.ts index 6095083a6c..c07c4e6adf 100644 --- a/src/app/core/cache/server-sync-buffer.actions.ts +++ b/src/app/core/cache/server-sync-buffer.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; @@ -12,7 +13,6 @@ export const ServerSyncBufferActionTypes = { EMPTY: type('dspace/core/cache/syncbuffer/EMPTY'), }; -/* tslint:disable:max-classes-per-file */ /** * An ngrx action to add a new cached object to the server sync buffer @@ -71,7 +71,6 @@ export class EmptySSBAction implements Action { } } -/* tslint:enable:max-classes-per-file */ /** * A type to encompass all ServerSyncBufferActions 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 a53c6af982..833c6b580f 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -83,7 +83,7 @@ describe('ServerSyncBufferEffects', () => { }); it('should return a COMMIT action in response to an ADD action', () => { - // tslint:disable-next-line:no-shadowed-variable + // eslint-disable-next-line @typescript-eslint/no-shadow testScheduler.run(({ hot, expectObservable }) => { actions = hot('a', { a: { diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index d8ed88e12c..9571d4af5b 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,6 +1,6 @@ import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { coreSelector } from '../core.selectors'; import { AddToSSBAction, @@ -8,7 +8,6 @@ import { EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; -import { CoreState } from '../core.reducers'; 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'; @@ -22,6 +21,7 @@ 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'; @Injectable() export class ServerSyncBufferEffects { @@ -32,7 +32,7 @@ export class ServerSyncBufferEffects { * Then dispatch a CommitSSBAction * When the delay is running, no new AddToSSBActions are processed in this effect */ - @Effect() setTimeoutForServerSync = this.actions$ + setTimeoutForServerSync = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.ADD), exhaustMap((action: AddToSSBAction) => { @@ -42,7 +42,7 @@ export class ServerSyncBufferEffects { delay(timeoutInSeconds * 1000), ); }) - ); + )); /** * When a CommitSSBAction is dispatched @@ -50,7 +50,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 */ - @Effect() commitServerSyncBuffer = this.actions$ + commitServerSyncBuffer = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.COMMIT), switchMap((action: CommitSSBAction) => { @@ -86,7 +86,7 @@ export class ServerSyncBufferEffects { }) ); }) - ); + )); /** * private method to create an ApplyPatchObjectCacheAction based on a cache entry 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 ce159500c6..51ba010c1e 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.spec.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -1,3 +1,4 @@ +// 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'; diff --git a/src/app/core/cache/typed-object.model.ts b/src/app/core/cache/typed-object.model.ts new file mode 100644 index 0000000000..02a530941a --- /dev/null +++ b/src/app/core/cache/typed-object.model.ts @@ -0,0 +1,6 @@ +import { ResourceType } from '../shared/resource-type'; + +export abstract class TypedObject { + static type: ResourceType; + type: ResourceType; +} diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config-data.service.spec.ts similarity index 80% rename from src/app/core/config/config.service.spec.ts rename to src/app/core/config/config-data.service.spec.ts index 1eca35d223..38340d1ad5 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config-data.service.spec.ts @@ -1,30 +1,34 @@ import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; -import { FindListOptions, GetRequest } from '../data/request.models'; +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'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; -class TestService extends ConfigService { +class TestService extends ConfigDataService { protected linkPath = LINK_NAME; protected browseEndpoint = BROWSE; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(requestService, rdbService, null, null, halService, null, null, null, BROWSE); + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(BROWSE, requestService, rdbService, objectCache, halService); } } -describe('ConfigService', () => { +describe('ConfigDataService', () => { let scheduler: TestScheduler; let service: TestService; let requestService: RequestService; @@ -44,7 +48,8 @@ describe('ConfigService', () => { return new TestService( requestService, rdbService, - halService + null, + halService, ); } diff --git a/src/app/core/config/config-data.service.ts b/src/app/core/config/config-data.service.ts new file mode 100644 index 0000000000..9ef2f11ad1 --- /dev/null +++ b/src/app/core/config/config-data.service.ts @@ -0,0 +1,40 @@ +import { Observable } from 'rxjs'; +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 { map } from 'rxjs/operators'; +import { BaseDataService } from '../data/base/base-data.service'; + +/** + * Abstract data service to retrieve configuration objects from the REST server. + * Common logic for configuration objects should be implemented here. + */ +export abstract class ConfigDataService extends BaseDataService { + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param href The url of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasFailed) { + throw new Error(`Couldn't retrieve the config`); + } else { + return rd; + } + }), + ); + } +} diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts deleted file mode 100644 index ddf909b5b0..0000000000 --- a/src/app/core/config/config.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Observable } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ConfigObject } from './models/config.model'; -import { RemoteData } from '../data/remote-data'; -import { DataService } from '../data/data.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { map } from 'rxjs/operators'; - -class DataServiceImpl extends DataService { - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, - protected linkPath: string - ) { - super(); - } -} - -// tslint:disable-next-line:max-classes-per-file -export abstract class ConfigService { - /** - * A private DataService instance to delegate specific methods to. - */ - private dataService: DataServiceImpl; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, - protected linkPath: string - ) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, this.linkPath); - } - - public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - if (rd.hasFailed) { - throw new Error(`Couldn't retrieve the config`); - } else { - return rd; - } - }) - ); - } -} diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 53250ee045..170aa334ed 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,8 +1,8 @@ import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../../cache/object-cache.reducer'; 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.service.ts b/src/app/core/config/submission-accesses-config-data.service.ts similarity index 55% rename from src/app/core/config/submission-accesses-config.service.ts rename to src/app/core/config/submission-accesses-config-data.service.ts index de9afc66ea..d2da0fce42 100644 --- a/src/app/core/config/submission-accesses-config.service.ts +++ b/src/app/core/config/submission-accesses-config-data.service.ts @@ -1,41 +1,46 @@ import { Injectable } from '@angular/core'; -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.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) -export class SubmissionAccessesConfigService extends ConfigService { +export class SubmissionAccessesConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionaccessoptions'); + super('submissionaccessoptions', requestService, rdbService, objectCache, halService); } + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param href The url of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable> { return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig[]) as Observable>; } diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config-data.service.ts similarity index 51% rename from src/app/core/config/submission-forms-config.service.ts rename to src/app/core/config/submission-forms-config-data.service.ts index a5c3f98060..f4c0690685 100644 --- a/src/app/core/config/submission-forms-config.service.ts +++ b/src/app/core/config/submission-forms-config-data.service.ts @@ -1,39 +1,46 @@ import { Injectable } from '@angular/core'; - -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ConfigObject } from './models/config.model'; -import { dataService } from '../cache/builders/build-decorators'; 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'; +/** + * Data service to retrieve submission form configuration objects from the REST server. + */ @Injectable() @dataService(SUBMISSION_FORMS_TYPE) -export class SubmissionFormsConfigService extends ConfigService { +export class SubmissionFormsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionforms'); + super('submissionforms', requestService, rdbService, objectCache, halService); } + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param href The url of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig[]) as Observable>; } diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config-data.service.ts similarity index 63% rename from src/app/core/config/submission-uploads-config.service.ts rename to src/app/core/config/submission-uploads-config-data.service.ts index a9e35a3183..8f838352a9 100644 --- a/src/app/core/config/submission-uploads-config.service.ts +++ b/src/app/core/config/submission-uploads-config-data.service.ts @@ -1,39 +1,30 @@ import { Injectable } from '@angular/core'; -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.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) -export class SubmissionUploadsConfigService extends ConfigService { +export class SubmissionUploadsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionuploads'); + super('submissionuploads', requestService, rdbService, objectCache, halService); } findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable> { diff --git a/src/app/core/core-state.model.ts b/src/app/core/core-state.model.ts new file mode 100644 index 0000000000..b8211fdb55 --- /dev/null +++ b/src/app/core/core-state.model.ts @@ -0,0 +1,30 @@ +import { + BitstreamFormatRegistryState +} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +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 { 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 + */ +export interface CoreState { + 'bitstreamFormats': BitstreamFormatRegistryState; + 'cache/object': ObjectCacheState; + 'cache/syncbuffer': ServerSyncBufferState; + 'cache/object-updates': ObjectUpdatesState; + 'data/request': RequestState; + 'history': HistoryState; + 'index': MetaIndexState; + 'auth': AuthState; + 'json/patch': JsonPatchOperationsState; + 'metaTag': MetaTagState; + 'route': RouteState; +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 8d8a614a89..ede23ba43b 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -2,15 +2,12 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; -import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/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 { FormBuilderService } from '../shared/form/builder/form-builder.service'; -import { FormService } from '../shared/form/form.service'; 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'; @@ -23,10 +20,7 @@ import { NotificationsService } from '../shared/notifications/notifications.serv 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 { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { UploaderService } from '../shared/uploader/uploader.service'; -import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthStatus } from './auth/models/auth-status.model'; import { BrowseService } from './browse/browse.service'; @@ -36,9 +30,9 @@ import { SubmissionDefinitionsModel } from './config/models/config-submission-de 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 { SubmissionFormsConfigService } from './config/submission-forms-config.service'; +import { SubmissionFormsConfigDataService } from './config/submission-forms-config-data.service'; import { coreEffects } from './core.effects'; -import { coreReducers, CoreState } from './core.reducers'; +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'; @@ -49,8 +43,8 @@ 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 { EntityTypeService } from './data/entity-type.service'; -import { ExternalSourceService } from './data/external-source.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'; @@ -58,9 +52,9 @@ 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 { RelationshipTypeService } from './data/relationship-type.service'; -import { RelationshipService } from './data/relationship.service'; -import { ResourcePolicyService } from './resource-policy/resource-policy.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'; @@ -75,7 +69,6 @@ import { RegistryService } from './registry/registry.service'; import { RoleService } from './roles/role.service'; import { FeedbackDataService } from './feedback/feedback-data.service'; -import { ApiService } from './services/api.service'; import { ServerResponseService } from './services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BitstreamFormat } from './shared/bitstream-format.model'; @@ -133,7 +126,9 @@ 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 { + 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'; @@ -146,7 +141,6 @@ import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-ent 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 { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; import { ReloadGuard } from './reload/reload.guard'; @@ -160,8 +154,22 @@ 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 { AccessStatusObject } from '../shared/object-list/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'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -186,7 +194,6 @@ const DECLARATIONS = []; const EXPORTS = []; const PROVIDERS = [ - ApiService, AuthenticatedGuard, CommunityDataService, CollectionDataService, @@ -194,20 +201,15 @@ const PROVIDERS = [ DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, - DynamicFormLayoutService, - DynamicFormService, - DynamicFormValidationService, - FormBuilderService, - SectionFormOperationsService, - FormService, EPersonDataService, + LinkHeadService, HALEndpointService, HostWindowService, ItemDataService, MetadataService, ObjectCacheService, PaginationComponentOptions, - ResourcePolicyService, + ResourcePolicyDataService, RegistryService, BitstreamFormatDataService, RemoteDataBuildService, @@ -219,37 +221,35 @@ const PROVIDERS = [ MyDSpaceResponseParsingService, ServerResponseService, BrowseService, + AccessStatusDataService, SubmissionCcLicenseDataService, SubmissionCcLicenseUrlDataService, - SubmissionFormsConfigService, + SubmissionFormsConfigDataService, SubmissionRestService, SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, JsonPatchOperationsBuilder, - UploaderService, UUIDService, NotificationsService, WorkspaceitemDataService, WorkflowItemDataService, - UploaderService, DSpaceObjectDataService, ConfigurationDataService, DSOChangeAnalyzer, DefaultChangeAnalyzer, ArrayMoveChangeAnalyzer, ObjectSelectService, - CSSVariableService, MenuService, ObjectUpdatesService, SearchService, - RelationshipService, + RelationshipDataService, MyDSpaceGuard, RoleService, TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, - EntityTypeService, + EntityTypeDataService, ContentSourceResponseParsingService, ItemTemplateDataService, SearchService, @@ -258,8 +258,8 @@ const PROVIDERS = [ SearchFilterService, SearchConfigurationService, SelectableListService, - RelationshipTypeService, - ExternalSourceService, + RelationshipTypeDataService, + ExternalSourceDataService, LookupRelationService, VersionDataService, VersionHistoryDataService, @@ -282,10 +282,16 @@ const PROVIDERS = [ FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory }, VocabularyService, - VocabularyTreeviewService, + VocabularyDataService, + VocabularyEntryDetailsDataService, SequenceService, GroupDataService, FeedbackDataService, + ResearcherProfileDataService, + ProfileClaimService, + OrcidAuthService, + OrcidQueueDataService, + OrcidHistoryDataService, ]; /** @@ -345,7 +351,12 @@ export const models = UsageReport, Root, SearchConfig, - SubmissionAccessesModel + SubmissionAccessesModel, + AccessStatusObject, + ResearcherProfile, + OrcidQueue, + OrcidHistory, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 8b3ec32b46..c0165c5384 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,33 +1,19 @@ import { ActionReducerMap, } from '@ngrx/store'; -import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; -import { indexReducer, MetaIndexState } from './index/index.reducer'; -import { requestReducer, RequestState } from './data/request.reducer'; -import { authReducer, AuthState } from './auth/auth.reducer'; -import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; -import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; -import { objectUpdatesReducer, ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; -import { routeReducer, RouteState } from './services/route.reducer'; +import { objectCacheReducer } from './cache/object-cache.reducer'; +import { indexReducer } from './index/index.reducer'; +import { requestReducer } from './data/request.reducer'; +import { authReducer } from './auth/auth.reducer'; +import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.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, - BitstreamFormatRegistryState + bitstreamFormatReducer } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; -import { historyReducer, HistoryState } from './history/history.reducer'; -import { metaTagReducer, MetaTagState } from './metadata/meta-tag.reducer'; - -export interface CoreState { - 'bitstreamFormats': BitstreamFormatRegistryState; - 'cache/object': ObjectCacheState; - 'cache/syncbuffer': ServerSyncBufferState; - 'cache/object-updates': ObjectUpdatesState; - 'data/request': RequestState; - 'history': HistoryState; - 'index': MetaIndexState; - 'auth': AuthState; - 'json/patch': JsonPatchOperationsState; - 'metaTag': MetaTagState; - 'route': RouteState; -} +import { historyReducer } from './history/history.reducer'; +import { metaTagReducer } from './metadata/meta-tag.reducer'; +import { CoreState } from './core-state.model'; export const coreReducers: ActionReducerMap = { 'bitstreamFormats': bitstreamFormatReducer, diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts index 60365be7c2..77c7974de2 100644 --- a/src/app/core/core.selectors.ts +++ b/src/app/core/core.selectors.ts @@ -1,5 +1,5 @@ import { createFeatureSelector } from '@ngrx/store'; -import { CoreState } from './core.reducers'; +import { CoreState } from './core-state.model'; /** * Base selector to select the core state from the store diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts new file mode 100644 index 0000000000..18b8cb5d65 --- /dev/null +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -0,0 +1,81 @@ +import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +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 { Item } from '../shared/item.model'; + +const url = 'fake-url'; + +describe('AccessStatusDataService', () => { + let service: AccessStatusDataService; + let requestService: RequestService; + let notificationsService: any; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; + + const itemId = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const mockItem: Item = Object.assign(new Item(), { + id: itemId, + name: 'test-item', + _links: { + accessStatus: { + href: `https://rest.api/items/${itemId}/accessStatus` + }, + self: { + href: `https://rest.api/items/${itemId}` + } + } + }); + + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); + }); + + describe('when calling findAccessStatusFor', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.findAccessStatusFor(mockItem); + }); + + it('should send a new GetRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), true); + })); + }); + }); + + /** + * Create an AccessStatusDataService used for testing + * @param reponse$ Supply a RemoteData to be returned by the REST API (optional) + */ + function createService(reponse$?: Observable>) { + requestService = getMockRequestService(); + let buildResponse$ = reponse$; + if (hasNoValue(reponse$)) { + buildResponse$ = createSuccessfulRemoteDataObject$({}); + } + rdbService = jasmine.createSpyObj('rdbService', { + buildFromRequestUUID: buildResponse$, + buildSingle: buildResponse$ + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + service = new AccessStatusDataService(requestService, rdbService, objectCache, halService); + } +}); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts new file mode 100644 index 0000000000..2f641456fa --- /dev/null +++ b/src/app/core/data/access-status-data.service.ts @@ -0,0 +1,37 @@ +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 './request.service'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/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'; + +/** + * Data service responsible for retrieving the access status of Items + */ +@Injectable() +@dataService(ACCESS_STATUS) +export class AccessStatusDataService extends BaseDataService { + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('accessStatus', requestService, rdbService, objectCache, halService); + } + + /** + * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item + * @param item Item we want the access status of + */ + findAccessStatusFor(item: Item): Observable> { + return this.findByHref(item._links.accessStatus.href); + } +} 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 94285d49d8..da9fa7a643 100644 --- a/src/app/core/data/base-response-parsing.service.spec.ts +++ b/src/app/core/data/base-response-parsing.service.spec.ts @@ -1,10 +1,11 @@ +/* eslint-disable max-classes-per-file */ import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { GetRequest, RestRequest } from './request.models'; +import { GetRequest} from './request.models'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; -/* tslint:disable:max-classes-per-file */ class TestService extends BaseResponseParsingService { toCache = true; @@ -101,4 +102,3 @@ describe('BaseResponseParsingService', () => { }); }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index b571b29f02..18e6623683 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -1,16 +1,16 @@ +/* eslint-disable max-classes-per-file */ import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { CacheableObject } from '../cache/object-cache.reducer'; 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 { RestRequest } from './request.models'; import { environment } from '../../../environments/environment'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; -/* tslint:disable:max-classes-per-file */ /** * Return true if halObj has a value for `_links.self` @@ -180,4 +180,3 @@ export abstract class BaseResponseParsingService { return statusCode >= 200 && statusCode < 300; } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts similarity index 62% rename from src/app/core/data/data.service.spec.ts rename to src/app/core/data/base/base-data.service.spec.ts index 5bc7423824..17532f477a 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -1,46 +1,39 @@ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; -import { compare, Operation } from 'fast-json-patch'; -import { Observable, of as observableOf } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { ChangeAnalyzer } from './change-analyzer'; -import { DataService } from './data.service'; -import { FindListOptions, PatchRequest } from './request.models'; -import { RequestService } from './request.service'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RequestParam } from '../cache/models/request-param.model'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +/** + * 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 { 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 { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from './remote-data'; -import { RequestEntryState } from './request.reducer'; +import { RemoteData } from '../remote-data'; +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'; -/* tslint:disable:max-classes-per-file */ -class TestService extends DataService { +const BOOLEAN = { f: false, t: true }; +class TestService extends BaseDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected linkPath: string, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: ChangeAnalyzer + protected halService: HALEndpointService, ) { - super(); + super(undefined, requestService, rdbService, objectCache, halService); } public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { @@ -48,24 +41,12 @@ class TestService extends DataService { } } -class DummyChangeAnalyzer implements ChangeAnalyzer { - diff(object1: Item, object2: Item): Operation[] { - return compare((object1 as any).metadata, (object2 as any).metadata); - } - -} - -describe('DataService', () => { +describe('BaseDataService', () => { let service: TestService; - let options: FindListOptions; let requestService; let halService; let rdbService; - let notificationsService; - let http; - let comparator; let objectCache; - let store; let selfLink; let linksToFollow; let testScheduler; @@ -75,9 +56,6 @@ describe('DataService', () => { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - notificationsService = {} as NotificationsService; - http = {} as HttpClient; - comparator = new DummyChangeAnalyzer() as any; objectCache = { addPatch: () => { @@ -85,9 +63,17 @@ describe('DataService', () => { }, getObjectBySelfLink: () => { /* empty */ - } + }, + getByHref: () => { + /* empty */ + }, + addDependency: () => { + /* empty */ + }, + removeDependents: () => { + /* empty */ + }, } as any; - store = {} as Store; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), @@ -115,17 +101,11 @@ describe('DataService', () => { ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; - return new TestService( requestService, rdbService, - store, - endpoint, - halService, objectCache, - notificationsService, - http, - comparator, + halService, ); } @@ -133,259 +113,6 @@ describe('DataService', () => { service = initTestService(); }); - describe('getFindAllHref', () => { - - it('should return an observable with the endpoint', () => { - options = {}; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(endpoint); - } - ); - }); - - it('should include page in href if currentPage provided in options', () => { - options = { currentPage: 2 }; - const expected = `${endpoint}?page=${options.currentPage - 1}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include size in href if elementsPerPage provided in options', () => { - options = { elementsPerPage: 5 }; - const expected = `${endpoint}?size=${options.elementsPerPage}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include sort href if SortOptions provided in options', () => { - const sortOptions = new SortOptions('field1', SortDirection.ASC); - options = { sort: sortOptions }; - const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include startsWith in href if startsWith provided in options', () => { - options = { startsWith: 'ab' }; - const expected = `${endpoint}?startsWith=${options.startsWith}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include all provided options in href', () => { - const sortOptions = new SortOptions('field1', SortDirection.DESC); - options = { - currentPage: 6, - elementsPerPage: 10, - sort: sortOptions, - startsWith: 'ab', - - }; - const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + - `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include all searchParams in href if any provided in options', () => { - options = { - searchParams: [ - new RequestParam('param1', 'test'), - new RequestParam('param2', 'test2'), - ] - }; - const expected = `${endpoint}?param1=test¶m2=test2`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include linkPath in href if any provided', () => { - const expected = `${endpoint}/test/entries`; - - (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include single linksToFollow as embed', () => { - const expected = `${endpoint}?embed=bundles`; - - (service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include single linksToFollow as embed and its size', () => { - const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`; - const config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 5 - }); - (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include multiple linksToFollow as embed', () => { - const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`; - - (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include multiple linksToFollow as embed and its sizes if given', () => { - const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`; - - const config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 2 - }); - - (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${endpoint}?embed=templateItemOf`; - - (service as any).getFindAllHref( - {}, - null, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include nested linksToFollow 3lvl', () => { - const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; - - (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include nested linksToFollow 2lvl and nested embed\'s size', () => { - const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`; - const config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 4 - }); - (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - }); - - describe('getIDHref', () => { - const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items'; - const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89'; - - it('should return endpoint', () => { - const result = (service as any).getIDHref(endpointMock, resourceIdMock); - expect(result).toEqual(endpointMock + '/' + resourceIdMock); - }); - - it('should include single linksToFollow as embed', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles')); - expect(result).toEqual(expected); - }); - - it('should include multiple linksToFollow as embed', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); - expect(result).toEqual(expected); - }); - - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; - const result = (service as any).getIDHref( - endpointMock, - resourceIdMock, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ); - expect(result).toEqual(expected); - }); - - it('should include nested linksToFollow 3lvl', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); - expect(result).toEqual(expected); - }); - }); - - describe('patch', () => { - const dso = { - uuid: 'dso-uuid' - }; - const operations = [ - Object.assign({ - op: 'move', - from: '/1', - path: '/5' - }) as Operation - ]; - - beforeEach(() => { - service.patch(dso, operations); - }); - - it('should send a PatchRequest', () => { - expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); - }); - }); - - describe('update', () => { - let operations; - let dso; - let dso2; - const name1 = 'random string'; - const name2 = 'another random string'; - beforeEach(() => { - operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; - - dso = Object.assign(new DSpaceObject(), { - _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name1 }] - }); - - dso2 = Object.assign(new DSpaceObject(), { - _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name2 }] - }); - - spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); - spyOn(objectCache, 'addPatch'); - }); - - it('should call addPatch on the object cache with the right parameters when there are differences', () => { - service.update(dso2).subscribe(); - expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); - }); - - it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { - service.update(dso).subscribe(); - expect(objectCache.addPatch).not.toHaveBeenCalled(); - }); - }); - describe(`reRequestStaleRemoteData`, () => { let callback: jasmine.Spy; @@ -650,7 +377,7 @@ describe('DataService', () => { }); - describe(`findAllByHref`, () => { + describe(`findListByHref`, () => { let findListOptions; beforeEach(() => { findListOptions = { currentPage: 5 }; @@ -663,7 +390,7 @@ describe('DataService', () => { spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); }); }); @@ -674,11 +401,11 @@ describe('DataService', () => { spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); - service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), false); expectObservable(rdbService.buildList.calls.argsFor(1)[0]).toBe('(a|)', { a: 'bingo!' }); }); @@ -690,29 +417,29 @@ describe('DataService', () => { spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); }); }); - it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findAllByHref call as a callback`, () => { + 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 })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findAllByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet - expect(service.findAllByHref).not.toHaveBeenCalled(); + expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData (service as any).reRequestStaleRemoteData.calls.argsFor(0)[1](); - // verify that findAllByHref _has_ been called now, with the same params as the original call - expect(service.findAllByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow); + // verify that findListByHref _has_ been called now, with the same params as the original call + expect(service.findListByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow); // ... except for selflink, which will have been turned in to an observable. - expectObservable((service.findAllByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); + expectObservable((service.findListByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); }); }); @@ -726,7 +453,7 @@ describe('DataService', () => { a: 'bingo!', }; - expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -754,7 +481,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -775,7 +502,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -805,7 +532,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -826,11 +553,129 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); }); }); + + describe('invalidateByHref', () => { + let getByHrefSpy: jasmine.Spy; + + beforeEach(() => { + getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2', 'request3'], + dependentRequestUUIDs: ['request4', 'request5'] + })); + + }); + + it('should call setStaleByUUID for every request associated with this DSO', (done) => { + service.invalidateByHref('some-href').subscribe((ok) => { + expect(ok).toBeTrue(); + expect(getByHrefSpy).toHaveBeenCalledWith('some-href'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5'); + done(); + }); + }); + + it('should call setStaleByUUID even if not subscribing to returned Observable', fakeAsync(() => { + service.invalidateByHref('some-href'); + tick(); + + expect(getByHrefSpy).toHaveBeenCalledWith('some-href'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5'); + })); + + it('should return an Observable that only emits true once all requests are stale', () => { + testScheduler.run(({ cold, expectObservable }) => { + requestService.setStaleByUUID.and.callFake((uuid) => { + switch (uuid) { // fake requests becoming stale at different times + case 'request1': + return cold('--(t|)', BOOLEAN); + case 'request2': + return cold('------(t|)', BOOLEAN); + case 'request3': + return cold('---(t|)', BOOLEAN); + case 'request4': + return cold('-(t|)', BOOLEAN); + case 'request5': + return cold('----(t|)', BOOLEAN); + } + }); + + const done$ = service.invalidateByHref('some-href'); + + // emit true as soon as the final request is stale + expectObservable(done$).toBe('------(t|)', BOOLEAN); + }); + }); + + it('should only fire for the current state of the object (instead of tracking it)', () => { + testScheduler.run(({ cold, flush }) => { + getByHrefSpy.and.returnValue(cold('a---b---c---', { + a: { requestUUIDs: ['request1'], dependentRequestUUIDs: [] }, // this is the state at the moment we're invalidating the cache + b: { requestUUIDs: ['request2'], dependentRequestUUIDs: [] }, // we shouldn't keep tracking the state + c: { requestUUIDs: ['request3'], dependentRequestUUIDs: [] }, // because we may invalidate when we shouldn't + })); + + service.invalidateByHref('some-href'); + flush(); + + // requests from the first state are marked as stale + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + + // request from subsequent states are ignored + expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request3'); + }); + }); + }); + + describe('addDependency', () => { + let addDependencySpy; + + beforeEach(() => { + addDependencySpy = spyOn(objectCache, 'addDependency'); + }); + + it('should call objectCache.addDependency with the object\'s self link', () => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe('object-href'); + expect(dependsOn).toBe('dependsOnHref'); + }); + }); + + (service as any).addDependency( + createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }), + observableOf('dependsOnHref') + ); + expect(addDependencySpy).toHaveBeenCalled(); + }); + + it('should call objectCache.addDependency without an href if request failed', () => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe(undefined); + expect(dependsOn).toBe('dependsOnHref'); + }); + }); + + (service as any).addDependency( + createFailedRemoteDataObject$('something went wrong'), + observableOf('dependsOnHref') + ); + expect(addDependencySpy).toHaveBeenCalled(); + }); + }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts new file mode 100644 index 0000000000..85603580a4 --- /dev/null +++ b/src/app/core/data/base/base-data.service.ts @@ -0,0 +1,412 @@ +/** + * 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 { 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 { RequestParam } from '../../cache/models/request-param.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { URLCombiner } from '../../url-combiner/url-combiner'; +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'; +/** + * Common functionality for data services. + * Specific functionality that not all services would need + * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * + * All DataService (or DataService feature) classes must + * - extend this class (or {@link IdentifiableDataService}) + * - implement any DataService features it requires in order to forward calls to it + * + * ``` + * export class SomeDataService extends BaseDataService implements CreateData, SearchData { + * private createData: CreateData; + * private searchData: SearchDataData; + * + * create(...) { + * return this.createData.create(...); + * } + * + * searchBy(...) { + * return this.searchData.searchBy(...); + * } + * } + * ``` + */ +export class BaseDataService implements HALDataService { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive?: number, + ) { + } + + /** + * Allows subclasses to reset the response cache time. + */ + + /** + * Get the endpoint for browsing + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @returns {Observable} + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.getEndpoint(); + } + + /** + * Get the base endpoint for all requests + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Turn an options object into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param options The [[FindListOptions]] object + * @param extraArgs Array with additional params to combine with query string + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig[]): string { + let args = [...extraArgs]; + + if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { + /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ + args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`); + } + if (hasValue(options.elementsPerPage)) { + args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`); + } + if (hasValue(options.sort)) { + args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`); + } + if (hasValue(options.startsWith)) { + args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`); + } + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + args = this.addEmbedParams(href, args, ...linksToFollow); + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + + /** + * Turn an array of RequestParam into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param params Array with additional params to combine with query string + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * + * @return {Observable} + * Return an observable that emits created HREF + */ + buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { + + let args = []; + if (hasValue(params)) { + params.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + + args = this.addEmbedParams(href, args, ...linksToFollow); + + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + /** + * Adds the embed options to the link for the request + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) { + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { + const embedString = 'embed=' + String(linkToFollow.name); + // Add the embeds size if given in the FollowLinkConfig.FindListOptions + if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { + args = this.addHrefArg(href, args, + 'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage); + } + // Adds the nested embeds and their size if given + if (isNotEmpty(linkToFollow.linksToFollow)) { + args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow); + } else { + args = this.addHrefArg(href, args, embedString); + } + } + }); + return args; + } + + /** + * Add a new argument to the list of arguments, only if it doesn't already exist in the given href, + * or the current list of arguments + * + * @param href The href the arguments are to be added to + * @param currentArgs The current list of arguments + * @param newArg The new argument to add + * @return The next list of arguments, with newArg included if it wasn't already. + * Note this function will not modify any of the input params. + */ + protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] { + if (href.includes(newArg) || currentArgs.includes(newArg)) { + return [...currentArgs]; + } else { + return [...currentArgs, newArg]; + } + } + + /** + * Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively + * @param embedString embedString so far (recursive) + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig[]): string[] { + let nestEmbed = embedString; + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { + nestEmbed = nestEmbed + EMBED_SEPARATOR + String(linkToFollow.name); + // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions + if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { + const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage; + args = this.addHrefArg(href, args, nestedEmbedSize); + } + if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) { + args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow); + } else { + args = this.addHrefArg(href, args, nestEmbed); + } + } + }); + return args; + } + + /** + * An operator that will call the given function if the incoming RemoteData is stale and + * shouldReRequest is true + * + * @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale + * @param requestFn The function to call if the RemoteData is stale and shouldReRequest is + * true + */ + protected reRequestStaleRemoteData(shouldReRequest: boolean, requestFn: () => Observable>) { + return (source: Observable>): Observable> => { + if (shouldReRequest === true) { + return source.pipe( + tap((remoteData: RemoteData) => { + if (hasValue(remoteData) && remoteData.isStale) { + requestFn(); + } + }) + ); + } else { + return source; + } + }; + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param href$ The url of object we want to retrieve. Can be a string or + * an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findByHref(href$: string | Observable, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + const requestHref$ = href$.pipe( + isNotEmptyOperator(), + take(1), + map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), + ); + + this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); + + return 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 + // cached completed object + skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + this.reRequestStaleRemoteData(reRequestOnStale, () => + this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } + + /** + * Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of list we want to retrieve. Can be a string or an Observable + * @param options + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findListByHref(href$: string | Observable, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + const requestHref$ = href$.pipe( + isNotEmptyOperator(), + take(1), + map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), + ); + + this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); + + return 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 + // cached completed object + skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + this.reRequestStaleRemoteData(reRequestOnStale, () => + this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } + + /** + * Create a GET request for the given href, and send it. + * + * @param href$ The url of object we want to retrieve. Can be a string or + * an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + */ + protected createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable = true): void { + if (isNotEmpty(href$)) { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + href$.pipe( + isNotEmptyOperator(), + take(1) + ).subscribe((href: string) => { + const requestId = this.requestService.generateRequestId(); + const request = new GetRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request, useCachedVersionIfAvailable); + }); + } + } + + /** + * Return the links to traverse from the root of the api to the + * endpoint this DataService represents + * + * e.g. if the api root links to 'foo', and the endpoint at 'foo' + * links to 'bar' the linkPath for the BarDataService would be + * 'foo/bar' + */ + getLinkPath(): string { + return this.linkPath; + } + + /** + * Shorthand method to add a dependency to a cached object + * ``` + * const out$ = this.findByHref(...); // or another method that sends a request + * this.addDependency(out$, dependsOnHref); + * ``` + * When {@link dependsOnHref$} is invalidated, {@link object$} will be invalidated as well. + * + * + * @param object$ the cached object + * @param dependsOnHref$ the href of the object it should depend on + */ + protected addDependency(object$: Observable>>, dependsOnHref$: string | Observable) { + this.objectCache.addDependency( + object$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return [rd.payload._links.self.href]; + } else { + // undefined href will be skipped in objectCache.addDependency + return [undefined]; + } + }), + ), + dependsOnHref$ + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + take(1), + switchMap((oce: ObjectCacheEntry) => { + return observableFrom([ + ...oce.requestUUIDs, + ...oce.dependentRequestUUIDs + ]).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + ); + }), + ).subscribe(() => { + this.objectCache.removeDependents(href); + done$.next(true); + done$.complete(); + }); + + return done$; + } +} diff --git a/src/app/core/data/base/create-data.spec.ts b/src/app/core/data/base/create-data.spec.ts new file mode 100644 index 0000000000..0b2e0f3930 --- /dev/null +++ b/src/app/core/data/base/create-data.spec.ts @@ -0,0 +1,225 @@ +/** + * 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 { 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 { 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 { 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'; + +/** + * Tests whether calls to `CreateData` methods are correctly patched through in a concrete data service that implements it + */ +export function testCreateDataImplementation(serviceFactory: () => CreateData) { + let service; + + describe('CreateData implementation', () => { + const OBJ = Object.assign(new DSpaceObject(), { + uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079', + }); + const PARAMS = [ + new RequestParam('abc', 123), new RequestParam('def', 456), + ]; + + beforeAll(() => { + service = serviceFactory(); + + (service as any).createData = jasmine.createSpyObj('createData', { + create: 'TEST create', + }); + }); + + it('should handle calls to create', () => { + const out: any = service.create(OBJ, ...PARAMS); + + expect((service as any).createData.create).toHaveBeenCalledWith(OBJ, ...PARAMS); + expect(out).toBe('TEST create'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends CreateDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('test', requestService, rdbService, objectCache, halService, notificationsService, undefined); + } + + public getEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('CreateDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let notificationsService; + let remoteDataMocks; + let obj; + + let MOCK_SUCCEEDED_RD; + let MOCK_FAILED_RD; + + let buildFromRequestUUIDSpy: jasmine.Spy; + let createOnEndpointSpy: jasmine.Spy; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + notificationsService = jasmine.createSpyObj('notificationsService', { + error: undefined, + }); + + obj = { + uuid: '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7', + }; + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + service = initTestService(); + + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough(); + createOnEndpointSpy = spyOn(service, 'createOnEndpoint').and.callThrough(); + + MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); + }); + + 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) + ]; + buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); + + service.create(obj, ...params).subscribe(out => { + expect(createOnEndpointSpy).toHaveBeenCalledWith(obj, jasmine.anything()); + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + uuid: requestService.generateRequestId(), + href: 'https://rest.api/core?abc=123&def=456', + body: JSON.stringify(obj), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(out).toEqual(remoteDataMocks.Success); + done(); + }); + }); + }); + + describe('createOnEndpoint', () => { + beforeEach(() => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); + }); + + it('should send a POST request with the object as JSON', (done) => { + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + body: JSON.stringify(obj), + })); + done(); + }); + }); + + it('should send the POST request to the given endpoint', (done) => { + + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + href: 'https://rest.api/core/custom?search', + })); + done(); + }); + }); + + it('should return the remote data for the sent request', (done) => { + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + uuid: requestService.generateRequestId(), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(notificationsService.error).not.toHaveBeenCalled(); + expect(out).toEqual(remoteDataMocks.Success); + done(); + }); + }); + + it('should show an error notification if the request fails', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Error)); + + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + uuid: requestService.generateRequestId(), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(notificationsService.error).toHaveBeenCalled(); + expect(out).toEqual(remoteDataMocks.Error); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts new file mode 100644 index 0000000000..3ffcd9adf2 --- /dev/null +++ b/src/app/core/data/base/create-data.ts @@ -0,0 +1,107 @@ +/** + * 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 { 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 { 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 { ObjectCacheService } from '../../cache/object-cache.service'; + +/** + * Interface for a data service that can create objects. + */ +export interface CreateData { + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable>; +} + +/** + * A DataService feature to create objects. + * + * Concrete data services can use this feature by implementing {@link CreateData} + * and delegating its method to an inner instance of this class. + */ +export class CreateDataImpl extends BaseDataService implements CreateData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + const endpoint$ = this.getEndpoint().pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpoint: string) => this.buildHrefWithParams(endpoint, params)), + ); + return this.createOnEndpoint(object, endpoint$); + } + + /** + * Send a POST request to create a new resource to a specific endpoint. + * Use this method if the endpoint needs to be adjusted. In most cases {@link create} should be sufficient. + * @param object the object to create + * @param endpoint$ the endpoint to send the POST request to + */ + createOnEndpoint(object: T, endpoint$: Observable): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object); + + endpoint$.pipe( + take(1), + ).subscribe((endpoint: string) => { + const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject)); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + const result$ = this.rdbService.buildFromRequestUUID(requestId); + + // TODO a dataservice is not the best place to show a notification, + // this should move up to the components that use this method + result$.pipe( + takeWhile((rd: RemoteData) => rd.isLoading, true) + ).subscribe((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); + } + }); + + return result$; + } +} diff --git a/src/app/core/data/base/data-service.decorator.spec.ts b/src/app/core/data/base/data-service.decorator.spec.ts new file mode 100644 index 0000000000..e09c531a56 --- /dev/null +++ b/src/app/core/data/base/data-service.decorator.spec.ts @@ -0,0 +1,56 @@ +/* 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 new file mode 100644 index 0000000000..fbde9bd94f --- /dev/null +++ b/src/app/core/data/base/data-service.decorator.ts @@ -0,0 +1,51 @@ +/** + * 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 new file mode 100644 index 0000000000..a076473b0f --- /dev/null +++ b/src/app/core/data/base/delete-data.spec.ts @@ -0,0 +1,230 @@ +/** + * 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 { constructIdEndpointDefault } from './identifiable-data.service'; +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 { 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 { 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'; + +/** + * Tests whether calls to `DeleteData` methods are correctly patched through in a concrete data service that implements it + */ +export function testDeleteDataImplementation(serviceFactory: () => DeleteData) { + let service; + + describe('DeleteData implementation', () => { + const ID = '2ce78f3a-791b-4d70-b5eb-753d587bbadd'; + const HREF = 'https://rest.api/core/items/' + ID; + const COPY_VIRTUAL_METADATA = [ + 'a', 'b', 'c' + ]; + + beforeAll(() => { + service = serviceFactory(); + (service as any).deleteData = jasmine.createSpyObj('deleteData', { + delete: 'TEST delete', + deleteByHref: 'TEST deleteByHref', + }); + }); + + it('should handle calls to delete', () => { + const out: any = service.delete(ID, COPY_VIRTUAL_METADATA); + + expect((service as any).deleteData.delete).toHaveBeenCalledWith(ID, COPY_VIRTUAL_METADATA); + expect(out).toBe('TEST delete'); + }); + + it('should handle calls to deleteByHref', () => { + const out: any = service.deleteByHref(HREF, COPY_VIRTUAL_METADATA); + + expect((service as any).deleteData.deleteByHref).toHaveBeenCalledWith(HREF, COPY_VIRTUAL_METADATA); + expect(out).toBe('TEST deleteByHref'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends DeleteDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, notificationsService, undefined, constructIdEndpointDefault); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('DeleteDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let notificationsService; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + notificationsService = {} as NotificationsService; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('delete', () => { + let MOCK_SUCCEEDED_RD; + let MOCK_FAILED_RD; + + let invalidateByHrefSpy: jasmine.Spy; + let buildFromRequestUUIDAndAwaitSpy: jasmine.Spy; + let getIDHrefObsSpy: jasmine.Spy; + let deleteByHrefSpy: jasmine.Spy; + + beforeEach(() => { + invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true)); + buildFromRequestUUIDAndAwaitSpy = spyOn(rdbService, 'buildFromRequestUUIDAndAwait').and.callThrough(); + getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough(); + deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough(); + + MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); + }); + + it('should retrieve href by ID and call deleteByHref', () => { + getIDHrefObsSpy.and.returnValue(observableOf('some-href')); + buildFromRequestUUIDAndAwaitSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); + + service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => { + expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id'); + expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']); + }); + }); + + describe('deleteByHref', () => { + it('should send a DELETE request', (done) => { + buildFromRequestUUIDAndAwaitSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href').subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.DELETE, + href: 'some-href', + })); + done(); + }); + }); + + it('should include the virtual metadata to be copied in the DELETE request', (done) => { + buildFromRequestUUIDAndAwaitSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href', ['a', 'b', 'c']).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.DELETE, + href: 'some-href?copyVirtualMetadata=a©VirtualMetadata=b©VirtualMetadata=c', + })); + done(); + }); + }); + + it('should invalidate the currently cached object', (done) => { + service.deleteByHref('some-href').subscribe(() => { + expect(buildFromRequestUUIDAndAwaitSpy).toHaveBeenCalledWith( + requestService.generateRequestId(), + jasmine.anything(), + ); + + const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1]; + callback(); + expect(service.invalidateByHref).toHaveBeenCalledWith('some-href'); + + done(); + }); + }); + + it('should return the RemoteData of the response', (done) => { + buildFromRequestUUIDAndAwaitSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_SUCCEEDED_RD); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/core/data/base/delete-data.ts b/src/app/core/data/base/delete-data.ts new file mode 100644 index 0000000000..807d9d838e --- /dev/null +++ b/src/app/core/data/base/delete-data.ts @@ -0,0 +1,87 @@ +/** + * 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 { 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 { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; + +export interface DeleteData { + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable>; + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable>; +} + +export class DeleteDataImpl extends IdentifiableDataService implements DeleteData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected responseMsToLive: number, + protected constructIdEndpoint: ConstructIdEndpoint, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint); + if (hasNoValue(constructIdEndpoint)) { + throw new Error(`DeleteDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`); + } + } + + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)), + ); + } + + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + if (copyVirtualMetadata) { + copyVirtualMetadata.forEach((id) => + href += (href.includes('?') ? '&' : '?') + + 'copyVirtualMetadata=' + + id, + ); + } + + const request = new DeleteRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => this.invalidateByHref(href)); + } +} diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts new file mode 100644 index 0000000000..6a73e032d0 --- /dev/null +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -0,0 +1,297 @@ +/** + * 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 { FindAllData, FindAllDataImpl } from './find-all-data'; +import { FindListOptions } from '../find-list-options.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +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 { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Observable, of as observableOf } from 'rxjs'; +import { EMBED_SEPARATOR } from './base-data.service'; + +/** + * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it + */ +export function testFindAllDataImplementation(serviceFactory: () => FindAllData) { + let service; + + describe('FindAllData implementation', () => { + const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 }); + const FOLLOWLINKS = [ + followLink('test'), + followLink('something'), + ]; + + beforeAll(() => { + service = serviceFactory(); + (service as any).findAllData = jasmine.createSpyObj('findAllData', { + findAll: 'TEST findAll', + }); + }); + + it('should handle calls to findAll', () => { + const out: any = service.findAll(OPTIONS, false, true, ...FOLLOWLINKS); + + expect((service as any).findAllData.findAll).toHaveBeenCalledWith(OPTIONS, false, true, ...FOLLOWLINKS); + expect(out).toBe('TEST findAll'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends FindAllDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('FindAllDataImpl', () => { + let service: TestService; + let options: FindListOptions; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getFindAllHref', () => { + + it('should return an observable with the endpoint', () => { + options = {}; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(endpoint); + }, + ); + }); + + it('should include page in href if currentPage provided in options', () => { + options = { currentPage: 2 }; + const expected = `${endpoint}?page=${options.currentPage - 1}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include size in href if elementsPerPage provided in options', () => { + options = { elementsPerPage: 5 }; + const expected = `${endpoint}?size=${options.elementsPerPage}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include sort href if SortOptions provided in options', () => { + const sortOptions = new SortOptions('field1', SortDirection.ASC); + options = { sort: sortOptions }; + const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include startsWith in href if startsWith provided in options', () => { + options = { startsWith: 'ab' }; + const expected = `${endpoint}?startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all provided options in href', () => { + const sortOptions = new SortOptions('field1', SortDirection.DESC); + options = { + currentPage: 6, + elementsPerPage: 10, + sort: sortOptions, + startsWith: 'ab', + + }; + const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all searchParams in href if any provided in options', () => { + options = { + searchParams: [ + new RequestParam('param1', 'test'), + new RequestParam('param2', 'test2'), + ], + }; + const expected = `${endpoint}?param1=test¶m2=test2`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include linkPath in href if any provided', () => { + const expected = `${endpoint}/test/entries`; + + (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include single linksToFollow as embed', () => { + const expected = `${endpoint}?embed=bundles`; + + (service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include single linksToFollow as embed and its size', () => { + const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`; + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 5, + }); + (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed', () => { + const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed and its sizes if given', () => { + const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`; + + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 2, + }); + + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${endpoint}?embed=templateItemOf`; + + (service as any).getFindAllHref( + {}, + null, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf'), + ).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 3lvl', () => { + const expected = `${endpoint}?embed=owningCollection${EMBED_SEPARATOR}itemtemplate${EMBED_SEPARATOR}relationships`; + + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 2lvl and nested embed\'s size', () => { + const expected = `${endpoint}?embed.size=owningCollection${EMBED_SEPARATOR}itemtemplate=4&embed=owningCollection${EMBED_SEPARATOR}itemtemplate`; + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 4, + }); + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + }); +}); diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts new file mode 100644 index 0000000000..57884e537e --- /dev/null +++ b/src/app/core/data/base/find-all-data.ts @@ -0,0 +1,101 @@ +/** + * 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 { 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 { isNotEmpty } 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'; + +/** + * Interface for a data service that list all of its objects. + */ +export interface FindAllData { + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; +} + +/** + * A DataService feature to list all objects. + * + * Concrete data services can use this feature by implementing {@link FindAllData} + * and delegating its method to an inner instance of this class. + */ +export class FindAllDataImpl extends BaseDataService implements FindAllData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findListByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + let endpoint$: Observable; + const args = []; + + endpoint$ = this.getBrowseEndpoint(options).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), + distinctUntilChanged(), + ); + + return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + } +} diff --git a/src/app/core/data/base/hal-data-service.interface.ts b/src/app/core/data/base/hal-data-service.interface.ts new file mode 100644 index 0000000000..6959399760 --- /dev/null +++ b/src/app/core/data/base/hal-data-service.interface.ts @@ -0,0 +1,41 @@ +/** + * 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 { Observable } from 'rxjs'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../remote-data'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { HALResource } from '../../shared/hal-resource.model'; + +/** + * An interface defining the minimum functionality needed for a data service to resolve HAL resources. + */ +export interface HALDataService { + /** + * Returns an Observable of {@link RemoteData} of an object, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of object we want to retrieve. Can be a string or an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href$: string | Observable, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>; + + /** + * Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of list we want to retrieve. Can be a string or an Observable + * @param findListOptions The options for to use for this find list request. + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findListByHref(href$: string | Observable, findListOptions?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; +} diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts new file mode 100644 index 0000000000..11af83ff9f --- /dev/null +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -0,0 +1,146 @@ +/** + * 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 { FindListOptions } from '../find-list-options.model'; +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 { EMBED_SEPARATOR } from './base-data.service'; + +const endpoint = 'https://rest.api/core'; + +class TestService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('IdentifiableDataService', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getIDHref', () => { + const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items'; + const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89'; + + it('should return endpoint', () => { + const result = (service as any).getIDHref(endpointMock, resourceIdMock); + expect(result).toEqual(endpointMock + '/' + resourceIdMock); + }); + + it('should include single linksToFollow as embed', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles')); + expect(result).toEqual(expected); + }); + + it('should include multiple linksToFollow as embed', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); + expect(result).toEqual(expected); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; + const result = (service as any).getIDHref( + endpointMock, + resourceIdMock, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf') + ); + expect(result).toEqual(expected); + }); + + it('should include nested linksToFollow 3lvl', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection${EMBED_SEPARATOR}itemtemplate${EMBED_SEPARATOR}relationships`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); + 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 new file mode 100644 index 0000000000..904f925765 --- /dev/null +++ b/src/app/core/data/base/identifiable-data.service.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 { 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 { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Shorthand type for the method to construct an ID endpoint. + */ +export type ConstructIdEndpoint = (endpoint: string, resourceID: string) => string; + +/** + * The default method to construct an ID endpoint + */ +export const constructIdEndpointDefault = (endpoint, resourceID) => `${endpoint}/${resourceID}`; + +/** + * A type of data service that deals with objects that have an ID. + * + * The effective endpoint to use for the ID can be adjusted by providing a different {@link ConstructIdEndpoint} method. + * This method is passed as an argument so that it can be set on data service features without having to override them. + */ +export class IdentifiableDataService extends BaseDataService { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive?: number, + protected constructIdEndpoint: ConstructIdEndpoint = constructIdEndpointDefault, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow + * @param endpoint The base endpoint for the type of object + * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { + return this.buildHrefFromFindOptions(this.constructIdEndpoint(endpoint, resourceID), {}, [], ...linksToFollow); + } + + /** + * Create an observable for the HREF of a specific object based on its identifier + * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.getEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); + } +} diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts new file mode 100644 index 0000000000..a55b1229b8 --- /dev/null +++ b/src/app/core/data/base/patch-data.spec.ts @@ -0,0 +1,249 @@ +/** + * 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 max-classes-per-file */ +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 { 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 { RestRequestMethod } from '../rest-request-method'; + +/** + * Tests whether calls to `PatchData` methods are correctly patched through in a concrete data service that implements it + */ +export function testPatchDataImplementation(serviceFactory: () => PatchData) { + let service; + + describe('PatchData implementation', () => { + const OBJ = Object.assign(new DSpaceObject(), { + uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079', + }); + const OPERATIONS = [ + { op: 'replace', path: '/0/value', value: 'test' }, + { op: 'add', path: '/2/value', value: 'test2' }, + ] as Operation[]; + const METHOD = RestRequestMethod.POST; + + beforeAll(() => { + service = serviceFactory(); + (service as any).patchData = jasmine.createSpyObj('patchData', { + patch: 'TEST patch', + update: 'TEST update', + commitUpdates: undefined, + createPatchFromCache: 'TEST createPatchFromCache', + }); + }); + + it('should handle calls to patch', () => { + const out: any = service.patch(OBJ, OPERATIONS); + + expect((service as any).patchData.patch).toHaveBeenCalledWith(OBJ, OPERATIONS); + expect(out).toBe('TEST patch'); + }); + + it('should handle calls to update', () => { + const out: any = service.update(OBJ); + + expect((service as any).patchData.update).toHaveBeenCalledWith(OBJ); + expect(out).toBe('TEST update'); + }); + + it('should handle calls to commitUpdates', () => { + service.commitUpdates(METHOD); + expect((service as any).patchData.commitUpdates).toHaveBeenCalledWith(METHOD); + }); + + it('should handle calls to createPatchFromCache', () => { + const out: any = service.createPatchFromCache(OBJ); + + expect((service as any).patchData.createPatchFromCache).toHaveBeenCalledWith(OBJ); + expect(out).toBe('TEST createPatchFromCache'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends PatchDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: ChangeAnalyzer, + ) { + super(undefined, requestService, rdbService, objectCache, halService, comparator, undefined, constructIdEndpointDefault); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: Item, object2: Item): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } +} + +describe('PatchDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let comparator; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + comparator = new DummyChangeAnalyzer() as any; + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + comparator, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('patch', () => { + const dso = { + uuid: 'dso-uuid', + _links: { + self: { + href: 'dso-href', + } + } + }; + const operations = [ + Object.assign({ + op: 'move', + from: '/1', + path: '/5' + }) as Operation + ]; + + it('should send a PatchRequest', () => { + service.patch(dso, operations); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); + }); + + it('should invalidate the cached object if successfully patched', () => { + spyOn(rdbService, 'buildFromRequestUUIDAndAwait'); + spyOn(service, 'invalidateByHref'); + + service.patch(dso, operations); + + expect(rdbService.buildFromRequestUUIDAndAwait).toHaveBeenCalled(); + expect((rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[0]).toBe(requestService.generateRequestId()); + const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1]; + callback(); + + expect(service.invalidateByHref).toHaveBeenCalledWith('dso-href'); + }); + }); + + describe('update', () => { + let operations; + let dso; + let dso2; + const name1 = 'random string'; + const name2 = 'another random string'; + beforeEach(() => { + operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; + + dso = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name1 }] + }); + + dso2 = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name2 }] + }); + + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); + spyOn(objectCache, 'addPatch'); + }); + + it('should call addPatch on the object cache with the right parameters when there are differences', () => { + service.update(dso2).subscribe(); + expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + }); + + it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { + service.update(dso).subscribe(); + expect(objectCache.addPatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts new file mode 100644 index 0000000000..e30c394a34 --- /dev/null +++ b/src/app/core/data/base/patch-data.ts @@ -0,0 +1,143 @@ +/** + * 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 { 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'; + + +/** + * Interface for a data service that can patch and update objects. + */ +export interface PatchData { + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable>; + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable>; + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod): void; + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + createPatchFromCache(object: T): Observable; +} + +/** + * A DataService feature to patch and update objects. + * + * Concrete data services can use this feature by implementing {@link PatchData} + * and delegating its method to an inner instance of this class. + * + * Note that this feature requires the object in question to have an ID. + * Make sure to use the same {@link ConstructIdEndpoint} as in the parent data service. + */ +export class PatchDataImpl extends IdentifiableDataService implements PatchData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + protected constructIdEndpoint: ConstructIdEndpoint, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint); + if (hasNoValue(constructIdEndpoint)) { + throw new Error(`PatchDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`); + } + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, object.uuid)), + ); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => this.invalidateByHref(object._links.self.href)); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable> { + return this.createPatchFromCache(object).pipe( + mergeMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object._links.self.href, operations); + } + return this.findByHref(object._links.self.href, true, true); + }), + ); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod): void { + this.requestService.commit(method); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + createPatchFromCache(object: T): Observable { + const oldVersion$ = this.findByHref(object._links.self.href, true, false); + return oldVersion$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + map((oldVersion: T) => this.comparator.diff(oldVersion, object)), + ); + } +} diff --git a/src/app/core/data/base/put-data.spec.ts b/src/app/core/data/base/put-data.spec.ts new file mode 100644 index 0000000000..6287fe91b1 --- /dev/null +++ b/src/app/core/data/base/put-data.spec.ts @@ -0,0 +1,176 @@ +/** + * 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 { 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 { 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 { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * Tests whether calls to `PutData` methods are correctly patched through in a concrete data service that implements it + */ +export function testPutDataImplementation(serviceFactory: () => PutData) { + let service; + + describe('PutData implementation', () => { + const OBJ = Object.assign(new DSpaceObject(), { + uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079', + }); + + beforeAll(() => { + service = serviceFactory(); + (service as any).putData = jasmine.createSpyObj('putData', { + put: 'TEST put', + }); + }); + + it('should handle calls to put', () => { + const out: any = service.put(OBJ); + + expect((service as any).putData.put).toHaveBeenCalledWith(OBJ); + expect(out).toBe('TEST put'); + }); + }); +} + + +const endpoint = 'https://rest.api/core'; + +class TestService extends PutDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('PutDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let remoteDataMocks; + + let obj; + let buildFromRequestUUIDSpy: jasmine.Spy; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + + obj = Object.assign(new DSpaceObject(), { + uuid: '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7', + 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 } }, + }); + + + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.returnValue(observableOf(remoteDataMocks.Success)); + }); + + describe('put', () => { + it('should send a PUT request with the serialized object', (done) => { + service.put(obj).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + body: { // _links are not serialized + uuid: obj.uuid, + metadata: obj.metadata + }, + })); + done(); + }); + }); + + it('should send the PUT request to the object\'s self link', (done) => { + service.put(obj).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + href: selfLink, + })); + done(); + }); + }); + + it('should return the remote data for the sent request', (done) => { + service.put(obj).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + uuid: requestService.generateRequestId(), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(out).toEqual(remoteDataMocks.Success); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts new file mode 100644 index 0000000000..bd2a8d2929 --- /dev/null +++ b/src/app/core/data/base/put-data.ts @@ -0,0 +1,69 @@ +/** + * 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 { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +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'; + +/** + * Interface for a data service that can send PUT requests. + */ +export interface PutData { + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable>; +} + +/** + * A DataService feature to send PUT requests. + * + * Concrete data services can use this feature by implementing {@link PutData} + * and delegating its method to an inner instance of this class. + */ +export class PutDataImpl extends BaseDataService implements PutData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const request = new PutRequest(requestId, object._links.self.href, serializedObject); + + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + + this.requestService.send(request); + + return this.rdbService.buildFromRequestUUID(requestId); + } +} diff --git a/src/app/core/data/base/search-data.spec.ts b/src/app/core/data/base/search-data.spec.ts new file mode 100644 index 0000000000..31dddeddfc --- /dev/null +++ b/src/app/core/data/base/search-data.spec.ts @@ -0,0 +1,141 @@ +/** + * 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 { 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'; + +/** + * Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it + */ +export function testSearchDataImplementation(serviceFactory: () => SearchData) { + let service; + + describe('SearchData implementation', () => { + const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 }); + const FOLLOWLINKS = [ + followLink('test'), + followLink('something'), + ]; + + beforeAll(() => { + service = serviceFactory(); + (service as any).searchData = jasmine.createSpyObj('searchData', { + searchBy: 'TEST searchBy', + }); + }); + + it('should handle calls to searchBy', () => { + const out: any = service.searchBy('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS); + + expect((service as any).searchData.searchBy).toHaveBeenCalledWith('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS); + expect(out).toBe('TEST searchBy'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +describe('SearchDataImpl', () => { + let service: SearchDataImpl; + let requestService; + let halService; + let rdbService; + let linksToFollow; + + let constructSearchEndpointSpy; + let options; + + function initTestService(): SearchDataImpl { + requestService = getMockRequestService(); + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpoint), + }); + rdbService = getMockRemoteDataBuildService(); + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + constructSearchEndpointSpy = jasmine.createSpy('constructSearchEndpointSpy').and.callFake(constructSearchEndpointDefault); + + options = Object.assign(new FindListOptions(), { + elementsPerPage: 5, + currentPage: 3, + }); + + return new SearchDataImpl( + 'test', + requestService, + rdbService, + undefined, + halService, + undefined, + constructSearchEndpointSpy, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getSearchEndpoint', () => { + it('should return the search endpoint for the given method', (done) => { + (service as any).getSearchEndpoint('testMethod').subscribe(searchEndpoint => { + expect(halService.getEndpoint).toHaveBeenCalledWith('test'); + expect(searchEndpoint).toBe('https://rest.api/core/search/testMethod'); + done(); + }); + }); + + it('should use constructSearchEndpoint to construct the search endpoint', (done) => { + (service as any).getSearchEndpoint('testMethod').subscribe(() => { + expect(constructSearchEndpointSpy).toHaveBeenCalledWith('https://rest.api/core', 'testMethod'); + done(); + }); + }); + }); + + describe('getSearchByHref', () => { + beforeEach(() => { + spyOn(service as any, 'getSearchEndpoint').and.callThrough(); + spyOn(service, 'buildHrefFromFindOptions').and.callThrough(); + }); + + it('should return the search endpoint with additional query parameters', (done) => { + service.getSearchByHref('testMethod', options, ...linksToFollow).subscribe(href => { + expect((service as any).getSearchEndpoint).toHaveBeenCalledWith('testMethod'); + expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith( + 'https://rest.api/core/search/testMethod', + options, + [], + ...linksToFollow, + ); + + expect(href).toBe('https://rest.api/core/search/testMethod?page=2&size=5&embed=a&embed=b'); + + done(); + }); + }); + }); + + describe('searchBy', () => { + it('should patch getSearchEndpoint into findListByHref and return the result', () => { + spyOn(service, 'getSearchByHref').and.returnValue('endpoint' as any); + spyOn(service, 'findListByHref').and.returnValue('resulting remote data' as any); + + const out: any = service.searchBy('testMethod', options, false, true, ...linksToFollow); + + expect(service.getSearchByHref).toHaveBeenCalledWith('testMethod', options, ...linksToFollow); + expect(service.findListByHref).toHaveBeenCalledWith('endpoint', undefined, false, true, ...linksToFollow); + expect(out).toBe('resulting remote data'); + }); + }); +}); diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts new file mode 100644 index 0000000000..536d6d6e25 --- /dev/null +++ b/src/app/core/data/base/search-data.ts @@ -0,0 +1,134 @@ +/** + * 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 { 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 { 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 { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Shorthand type for method to construct a search endpoint + */ +export type ConstructSearchEndpoint = (href: string, searchMethod: string) => string; + +/** + * Default method to construct a search endpoint + */ +export const constructSearchEndpointDefault = (href: string, searchMethod: string): string => `${href}/search/${searchMethod}`; + +/** + * Interface for a data service that can search for objects. + */ +export interface SearchData { + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; +} + +/** + * A DataService feature to search for objects. + * + * Concrete data services can use this feature by implementing {@link SearchData} + * and delegating its method to an inner instance of this class. + */ +export class SearchDataImpl extends BaseDataService implements SearchData { + /** + * @param linkPath + * @param requestService + * @param rdbService + * @param objectCache + * @param halService + * @param responseMsToLive + * @param constructSearchEndpoint an optional method to construct the search endpoint, passed as an argument so it can be + * modified without extending this class. Defaults to `${href}/search/${searchMethod}` + */ + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + private constructSearchEndpoint: ConstructSearchEndpoint = constructSearchEndpointDefault, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + if (hasNoValue(constructSearchEndpoint)) { + throw new Error(`SearchDataImpl initialized without a constructSearchEndpoint method (linkPath: ${linkPath})`); + } + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); + + return this.findListByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { + let result$: Observable; + const args = []; + + result$ = this.getSearchEndpoint(searchMethod); + + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + } + + /** + * Return object search endpoint by given search method + * + * @param searchMethod The search method for the object + */ + private getSearchEndpoint(searchMethod: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map(href => this.constructSearchEndpoint(href, searchMethod)), + ); + } +} diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index df170397f8..c67eaa2d68 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -12,6 +12,9 @@ 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'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -47,7 +50,14 @@ describe('BitstreamDataService', () => { }); rdbService = getMockRemoteDataBuildService(); - service = new BitstreamDataService(requestService, rdbService, null, objectCache, halService, null, null, null, null, bitstreamFormatService); + service = new BitstreamDataService(requestService, rdbService, objectCache, halService, null, bitstreamFormatService, null, null); + }); + + describe('composition', () => { + const initService = () => new BitstreamDataService(null, null, null, null, null, null, null, null); + testSearchDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); }); describe('when updating the bitstream\'s format', () => { diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 23aec80ff2..6bdcefe187 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,62 +1,67 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { map, switchMap, 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 { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { Bitstream } from '../shared/bitstream.model'; import { BITSTREAM } from '../shared/bitstream.resource-type'; 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 { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { FindListOptions, PutRequest } from './request.models'; +import { 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 { sendRequest } from '../shared/operators'; 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 } from 'fast-json-patch'; /** * A service to retrieve {@link Bitstream}s from the REST API */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(BITSTREAM) -export class BitstreamDataService extends DataService { - - /** - * The HAL path to the bitstream endpoint - */ - protected linkPath = 'bitstreams'; +export class BitstreamDataService extends IdentifiableDataService implements SearchData, PatchData, DeleteData { + private searchData: SearchDataImpl; + private patchData: PatchDataImpl; + private deleteData: DeleteDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, protected bundleService: BundleDataService, - protected bitstreamFormatService: BitstreamFormatDataService + protected bitstreamFormatService: BitstreamFormatDataService, + protected comparator: DSOChangeAnalyzer, + protected notificationsService: NotificationsService, ) { - super(); + super('bitstreams', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -72,7 +77,7 @@ export class BitstreamDataService extends DataService { * {@link HALLink}s should be automatically resolved */ findAllByBundle(bundle: Bundle, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findListByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -179,8 +184,97 @@ export class BitstreamDataService extends DataService { hrefObs, useCachedVersionIfAvailable, reRequestOnStale, - ...linksToFollow + ...linksToFollow, ); } + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: Bitstream, operations: []): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: Bitstream): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: Bitstream): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } 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 c072803c83..15efebe8c7 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -1,5 +1,4 @@ import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { RequestEntry } from './request.reducer'; import { RestResponse } from '../cache/response.models'; import { Observable, of as observableOf } from 'rxjs'; import { Action, Store } from '@ngrx/store'; @@ -7,18 +6,16 @@ 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 { HttpClient } from '@angular/common/http'; 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 { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { TestScheduler } from 'rxjs/testing'; -import { CoreState } from '../core.reducers'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { CoreState } from '../core-state.model'; +import { RequestEntry } from './request-entry.model'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('BitstreamFormatDataService', () => { let service: BitstreamFormatDataService; @@ -37,7 +34,12 @@ describe('BitstreamFormatDataService', () => { } } as Store; - const objectCache = {} as ObjectCacheService; + const requestUUIDs = ['some', 'uuid']; + + const objectCache = jasmine.createSpyObj('objectCache', { + getByHref: observableOf({ requestUUIDs }) + }) as ObjectCacheService; + const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: bitstreamFormatsEndpoint }); @@ -45,8 +47,6 @@ describe('BitstreamFormatDataService', () => { } as HALEndpointService; const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; let rd; let rdbService: RemoteDataBuildService; @@ -54,21 +54,26 @@ describe('BitstreamFormatDataService', () => { function initTestService(halService) { rd = createSuccessfulRemoteDataObject({}); rdbService = jasmine.createSpyObj('rdbService', { - buildFromRequestUUID: observableOf(rd) + buildFromRequestUUID: observableOf(rd), + buildFromRequestUUIDAndAwait: observableOf(rd), }); return new BitstreamFormatDataService( requestService, rdbService, - store, objectCache, halService, notificationsService, - http, - comparator + store, ); } + describe('composition', () => { + const initService = () => new BitstreamFormatDataService(null, null, null, null, null, null); + testFindAllDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('getBrowseEndpoint', () => { beforeEach(waitForAsync(() => { scheduler = getTestScheduler(); @@ -76,6 +81,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -96,6 +102,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -118,6 +125,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -139,6 +147,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -163,6 +172,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -186,6 +196,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -209,6 +220,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -231,6 +243,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -253,6 +266,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -273,6 +287,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: hot('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 0d0dc5eb63..0104389815 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,57 +1,64 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; 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 { 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 { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; 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 { sendRequest } from '../shared/operators'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.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 { 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'; const bitstreamFormatsStateSelector = createSelector( coreSelector, - (state: CoreState) => state.bitstreamFormats + (state: CoreState) => state.bitstreamFormats, +); +const selectedBitstreamFormatSelector = createSelector( + bitstreamFormatsStateSelector, + (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats, ); -const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSelector, - (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats); /** * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint */ @Injectable() @dataService(BITSTREAM_FORMAT) -export class BitstreamFormatDataService extends DataService { +export class BitstreamFormatDataService extends IdentifiableDataService implements FindAllData, DeleteData { protected linkPath = 'bitstreamformats'; + private findAllData: FindAllDataImpl; + private deleteData: DeleteDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected store: Store, + ) { + super('bitstreamformats', 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); } /** @@ -60,7 +67,7 @@ export class BitstreamFormatDataService extends DataService { */ public getUpdateEndpoint(formatId: string): Observable { return this.getBrowseEndpoint().pipe( - map((endpoint: string) => this.getIDHref(endpoint, formatId)) + map((endpoint: string) => this.getIDHref(endpoint, formatId)), ); } @@ -147,4 +154,47 @@ export class BitstreamFormatDataService extends DataService { findByBitstream(bitstream: Bitstream): Observable> { return this.findByHref(bitstream._links.format.href); } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index ed149a624f..e3ba438f9b 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -3,7 +3,6 @@ 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 { CoreState } from '../core.reducers'; import { Item } from '../shared/item.model'; import { ChangeAnalyzer } from './change-analyzer'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; @@ -13,6 +12,8 @@ import { HALLink } from '../shared/hal-link.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { Bundle } from '../shared/bundle.model'; +import { CoreState } from '../core-state.model'; +import { testPatchDataImplementation } from './base/patch-data.spec'; class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { @@ -64,9 +65,6 @@ describe('BundleDataService', () => { store, objectCache, halService, - notificationsService, - http, - comparator, ); } @@ -74,14 +72,20 @@ describe('BundleDataService', () => { service = initTestService(); }); + describe('composition', () => { + const initService = () => new BundleDataService(null, null, null, null, null); + + testPatchDataImplementation(initService); + }); + describe('findAllByItem', () => { beforeEach(() => { - spyOn(service, 'findAllByHref'); + spyOn(service, 'findListByHref'); service.findAllByItem(item); }); - it('should call findAllByHref with the item\'s bundles link', () => { - expect(service.findAllByHref).toHaveBeenCalledWith(bundleLink, undefined, true, true); + it('should call findListByHref with the item\'s bundles link', () => { + expect(service.findListByHref).toHaveBeenCalledWith(bundleLink, undefined, true, true); }); }); diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 3c885c0afd..19f0e73706 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,50 +1,51 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map, switchMap, 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 { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; 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 { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { FindListOptions, GetRequest } from './request.models'; +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.reducer'; +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'} + { providedIn: 'root' }, ) @dataService(BUNDLE) -export class BundleDataService extends DataService { - protected linkPath = 'bundles'; - protected bitstreamsEndpoint = 'bitstreams'; +export class BundleDataService extends IdentifiableDataService implements PatchData { + private bitstreamsEndpoint = 'bitstreams'; + + private patchData: PatchDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected comparator: DSOChangeAnalyzer, + ) { + super('bundles', requestService, rdbService, objectCache, halService); + + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -60,7 +61,7 @@ export class BundleDataService extends DataService { * {@link HALLink}s should be automatically resolved */ findAllByItem(item: Item, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.findAllByHref(item._links.bundles.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findListByHref(item._links.bundles.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -132,7 +133,7 @@ export class BundleDataService extends DataService { const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions); hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const request = new GetRequest(this.requestService.generateRequestId(), href); this.requestService.send(request, true); @@ -140,4 +141,38 @@ export class BundleDataService extends DataService { return this.rdbService.buildList(hrefObs, ...linksToFollow); } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: Bundle, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: Bundle): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: Bundle): Observable { + return this.patchData.createPatchFromCache(object); + } } diff --git a/src/app/core/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts index 8efe26314e..45fd9b7e84 100644 --- a/src/app/core/data/change-analyzer.ts +++ b/src/app/core/data/change-analyzer.ts @@ -1,6 +1,6 @@ import { Operation } from 'fast-json-patch'; -import { TypedObject } from '../cache/object-cache.reducer'; +import { TypedObject } from '../cache/typed-object.model'; /** * An interface to determine what differs between two diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 031e5ecf47..65f8b3ab2c 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -13,16 +13,17 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { Collection } from '../shared/collection.model'; import { PageInfo } from '../shared/page-info.model'; import { buildPaginatedList } from './paginated-list.model'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ -} from '../../shared/remote-data.utils'; +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'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -75,6 +76,16 @@ describe('CollectionDataService', () => { const paginatedList = buildPaginatedList(pageInfo, array); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + describe('composition', () => { + const initService = () => new CollectionDataService(null, null, null, null, null, null, null, null, null); + + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testSearchDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('when the requests are successful', () => { beforeEach(() => { createService(); @@ -201,7 +212,7 @@ describe('CollectionDataService', () => { notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, null, null, objectCache, halService, notificationsService, null, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache, 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 470c036df2..405b35c1f9 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,5 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; @@ -9,11 +8,9 @@ import { NotificationOptions } from '../../shared/notifications/models/notificat import { INotification } from '../../shared/notifications/models/notification.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; 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 { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { Collection } from '../shared/collection.model'; @@ -27,31 +24,35 @@ import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { ContentSourceRequest, FindListOptions, RestRequest, UpdateContentSourceRequest } from './request.models'; +import { + ContentSourceRequest, + 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) export class CollectionDataService extends ComColDataService { - protected linkPath = 'collections'; protected errorTitle = 'collection.source.update.notifications.error.title'; protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected cds: CommunityDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, protected notificationsService: NotificationsService, - protected http: HttpClient, protected bitstreamDataService: BitstreamDataService, - protected comparator: DSOChangeAnalyzer, - protected translate: TranslateService + protected communityDataService: CommunityDataService, + protected translate: TranslateService, ) { - super(); + super('collections', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } /** @@ -279,7 +280,15 @@ export class CollectionDataService extends ComColDataService { * @param findListOptions Pagination and search options. */ findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable>> { - return this.findAllByHref(item._links.mappedCollections.href, findListOptions); + return this.findListByHref(item._links.mappedCollections.href, findListOptions); } + + protected getScopeCommunityHref(options: FindListOptions) { + return this.communityDataService.getEndpoint().pipe( + map((endpoint: string) => this.communityDataService.getIDHref(endpoint, options.scopeID)), + filter((href: string) => isNotEmpty(href)), + take(1), + ); + } } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 864c583dc2..0f9f0fa740 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -7,24 +7,31 @@ 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 { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { FindListOptions } from './request.models'; import { RequestService } from './request.service'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject$, - createFailedRemoteDataObject, - createSuccessfulRemoteDataObject -} from '../../shared/remote-data.utils'; +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'; const LINK_NAME = 'test'; +const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; + +const communitiesEndpoint = 'https://rest.api/core/communities'; + +const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; + class TestService extends ComColDataService { constructor( @@ -40,16 +47,21 @@ class TestService extends ComColDataService { protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { - super(); + super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } protected getFindByParentHref(parentUUID: string): Observable { // implementation in subclasses for communities/collections return undefined; } + + protected getScopeCommunityHref(options: FindListOptions): Observable { + // implementation in subclasses for communities/collections + return observableOf(communityEndpoint); + } } -// tslint:disable:no-shadowed-variable +/* eslint-disable @typescript-eslint/no-shadow */ describe('ComColDataService', () => { let service: TestService; let requestService: RequestService; @@ -66,12 +78,9 @@ describe('ComColDataService', () => { const http = {} as HttpClient; const comparator = {} as any; - const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const options = Object.assign(new FindListOptions(), { scopeID: scopeID }); - const communitiesEndpoint = 'https://rest.api/core/communities'; - const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; const mockHalService = { @@ -145,6 +154,15 @@ describe('ComColDataService', () => { service = initTestService(); }); + describe('composition', () => { + const initService = () => new TestService(null, null, null, null, null, null, null, null, null, null, null); + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testSearchDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('getBrowseEndpoint', () => { it(`should call createAndSendGetRequest with the scope Community's self link`, () => { testScheduler.run(({ cold, flush, expectObservable }) => { @@ -236,4 +254,75 @@ describe('ComColDataService', () => { }); }); }); + + describe('deleteLogo', () => { + let dso; + + beforeEach(() => { + dso = { + _links: { + logo: { + href: 'logo-href' + } + } + }; + }); + + describe('when DSO has no logo', () => { + beforeEach(() => { + dso.logo = undefined; + }); + + it('should return a failed RD', (done) => { + service.deleteLogo(dso).subscribe(rd => { + expect(rd.hasFailed).toBeTrue(); + expect(bitstreamDataService.deleteByHref).not.toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('when DSO has a logo', () => { + let logo; + + beforeEach(() => { + logo = Object.assign(new Bitstream, { + id: 'logo-id', + _links: { + self: { + href: 'logo-href', + } + } + }); + }); + + describe('that can be retrieved', () => { + beforeEach(() => { + dso.logo = createSuccessfulRemoteDataObject$(logo); + }); + + it('should call BitstreamDataService.deleteByHref', (done) => { + service.deleteLogo(dso).subscribe(rd => { + expect(rd.hasSucceeded).toBeTrue(); + expect(bitstreamDataService.deleteByHref).toHaveBeenCalledWith('logo-href'); + done(); + }); + }); + }); + + describe('that cannot be retrieved', () => { + beforeEach(() => { + dso.logo = createFailedRemoteDataObject$(logo); + }); + + it('should not call BitstreamDataService.deleteByHref', (done) => { + service.deleteLogo(dso).subscribe(rd => { + expect(rd.hasFailed).toBeTrue(); + expect(bitstreamDataService.deleteByHref).not.toHaveBeenCalled(); + done(); + }); + }); + }); + }); + }); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 12aedf8009..abc9046cd0 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -4,10 +4,6 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { HALLink } from '../shared/hal-link.model'; -import { CommunityDataService } from './community-data.service'; - -import { DataService } from './data.service'; -import { FindListOptions } from './request.models'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -19,12 +15,46 @@ import { NoContent } from '../shared/NoContent.model'; import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; import { URLCombiner } from '../url-combiner/url-combiner'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from './find-list-options.model'; +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 { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { Operation } from 'fast-json-patch'; -export abstract class ComColDataService extends DataService { - protected abstract cds: CommunityDataService; - protected abstract objectCache: ObjectCacheService; - protected abstract halService: HALEndpointService; - protected abstract bitstreamDataService: BitstreamDataService; +export abstract class ComColDataService extends IdentifiableDataService implements CreateData, FindAllData, SearchData, PatchData, DeleteData { + private createData: CreateData; + private findAllData: FindAllData; + private searchData: SearchData; + private patchData: PatchData; + private deleteData: DeleteData; + + protected constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, + protected notificationsService: NotificationsService, + protected bitstreamDataService: BitstreamDataService, + ) { + super(linkPath, requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + } /** * Get the scoped endpoint URL by fetching the object with @@ -40,11 +70,7 @@ export abstract class ComColDataService extend if (isEmpty(options.scopeID)) { return this.halService.getEndpoint(linkPath); } else { - const scopeCommunityHrefObs = this.cds.getEndpoint().pipe( - map((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), - filter((href: string) => isNotEmpty(href)), - take(1) - ); + const scopeCommunityHrefObs = this.getScopeCommunityHref(options); this.createAndSendGetRequest(scopeCommunityHrefObs, true); @@ -65,13 +91,15 @@ export abstract class ComColDataService extend } } + protected abstract getScopeCommunityHref(options: FindListOptions): Observable; + protected abstract getFindByParentHref(parentUUID: string): Observable; public findByParent(parentUUID: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getFindByParentHref(parentUUID).pipe( map((href: string) => this.buildHrefFromFindOptions(href, options)) ); - return this.findAllByHref(href$, options, true, true, ...linksToFollow); + return this.findListByHref(href$, options, true, true, ...linksToFollow); } /** @@ -134,4 +162,110 @@ export abstract class ComColDataService extend const parentCommunity = dso._links.parentCommunity; return isNotEmpty(parentCommunity) ? parentCommunity.href : null; } + + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: T, operations: []): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: T): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: T): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 8dee72e391..efb6d50e84 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,14 +1,10 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; import { COMMUNITY } from '../shared/community.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -16,39 +12,40 @@ import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { FindListOptions } from './request.models'; 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) export class CommunityDataService extends ComColDataService { - protected linkPath = 'communities'; protected topLinkPath = 'search/top'; - protected cds = this; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, protected notificationsService: NotificationsService, protected bitstreamDataService: BitstreamDataService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer ) { - super(); + super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } + // this method is overridden in order to make it public getEndpoint() { return this.halService.getEndpoint(this.linkPath); } findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const hrefObs = this.getFindAllHref(options, this.topLinkPath); - return this.findAllByHref(hrefObs, undefined, true, true, ...linksToFollow); + return this.getEndpoint().pipe( + map(href => `${href}/search/top`), + switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)) + ); } protected getFindByParentHref(parentUUID: string): Observable { @@ -58,4 +55,11 @@ export class CommunityDataService extends ComColDataService { ); } + protected getScopeCommunityHref(options: FindListOptions) { + return this.getEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, options.scopeID)), + filter((href: string) => isNotEmpty(href)), + 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 7077f098e0..7fe69c16e5 100644 --- a/src/app/core/data/configuration-data.service.spec.ts +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -5,8 +5,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { ConfigurationDataService } from './configuration-data.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; @@ -44,18 +42,12 @@ describe('ConfigurationDataService', () => { }) }); objectCache = {} as ObjectCacheService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; service = new ConfigurationDataService( requestService, rdbService, objectCache, halService, - notificationsService, - http, - comparator ); }); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index 91d5af6ecc..de044e25e3 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,55 +1,30 @@ -import { HttpClient } from '@angular/common/http'; +/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; - -/* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { - protected linkPath = 'properties'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(CONFIG_PROPERTY) /** * Data Service responsible for retrieving Configuration properties */ -export class ConfigurationDataService { - protected linkPath = 'properties'; - private dataService: DataServiceImpl; +export class ConfigurationDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super('properties', requestService, rdbService, objectCache, halService); } /** @@ -57,7 +32,6 @@ export class ConfigurationDataService { * @param name */ findByPropertyName(name: string): Observable> { - return this.dataService.findById(name); + return this.findById(name); } } -/* tslint:enable:max-classes-per-file */ 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 42b8f85c42..066ccf28c9 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -4,8 +4,8 @@ import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { ContentSource } from '../shared/content-source.model'; import { MetadataConfig } from '../shared/metadata-config.model'; -import { RestRequest } from './request.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { RestRequest } from './rest-request.model'; @Injectable() /** diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts deleted file mode 100644 index 6bad02e776..0000000000 --- a/src/app/core/data/data.service.ts +++ /dev/null @@ -1,643 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; -import { Operation } from 'fast-json-patch'; -import { Observable, of as observableOf } from 'rxjs'; -import { - distinctUntilChanged, - filter, - find, - map, - mergeMap, - take, - takeWhile, - switchMap, - tap, - skipWhile, -} from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -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 { getClassForType } from '../cache/builders/build-decorators'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { RequestParam } from '../cache/models/request-param.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getRemoteDataPayload, getFirstSucceededRemoteData, } from '../shared/operators'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { ChangeAnalyzer } from './change-analyzer'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { - CreateRequest, - GetRequest, - FindListOptions, - PatchRequest, - PutRequest, - DeleteRequest -} from './request.models'; -import { RequestService } from './request.service'; -import { RestRequestMethod } from './rest-request-method'; -import { UpdateDataService } from './update-data.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { NoContent } from '../shared/NoContent.model'; - -export abstract class DataService implements UpdateDataService { - protected abstract requestService: RequestService; - protected abstract rdbService: RemoteDataBuildService; - protected abstract store: Store; - protected abstract linkPath: string; - protected abstract halService: HALEndpointService; - protected abstract objectCache: ObjectCacheService; - protected abstract notificationsService: NotificationsService; - protected abstract http: HttpClient; - protected abstract comparator: ChangeAnalyzer; - - /** - * Allows subclasses to reset the response cache time. - */ - protected responseMsToLive: number; - - /** - * Get the endpoint for browsing - * @param options The [[FindListOptions]] object - * @param linkPath The link path for the object - * @returns {Observable} - */ - getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { - return this.getEndpoint(); - } - - /** - * Get the base endpoint for all requests - */ - protected getEndpoint(): Observable { - return this.halService.getEndpoint(this.linkPath); - } - - /** - * Create the HREF with given options object - * - * @param options The [[FindListOptions]] object - * @param linkPath The link path for the object - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { - let endpoint$: Observable; - const args = []; - - endpoint$ = this.getBrowseEndpoint(options).pipe( - filter((href: string) => isNotEmpty(href)), - map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), - distinctUntilChanged() - ); - - return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); - } - - /** - * Create the HREF for a specific object's search method with given options object - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { - let result$: Observable; - const args = []; - - result$ = this.getSearchEndpoint(searchMethod); - - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); - } - - /** - * Turn an options object into a query string and combine it with the given HREF - * - * @param href The HREF to which the query string should be appended - * @param options The [[FindListOptions]] object - * @param extraArgs Array with additional params to combine with query string - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig[]): string { - let args = [...extraArgs]; - - if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { - /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ - args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`); - } - if (hasValue(options.elementsPerPage)) { - args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`); - } - if (hasValue(options.sort)) { - args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`); - } - if (hasValue(options.startsWith)) { - args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`); - } - if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: RequestParam) => { - args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); - }); - } - args = this.addEmbedParams(href, args, ...linksToFollow); - if (isNotEmpty(args)) { - return new URLCombiner(href, `?${args.join('&')}`).toString(); - } else { - return href; - } - } - - /** - * Turn an array of RequestParam into a query string and combine it with the given HREF - * - * @param href The HREF to which the query string should be appended - * @param params Array with additional params to combine with query string - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - * - * @return {Observable} - * Return an observable that emits created HREF - */ - protected buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { - - let args = []; - if (hasValue(params)) { - params.forEach((param: RequestParam) => { - args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); - }); - } - - args = this.addEmbedParams(href, args, ...linksToFollow); - - if (isNotEmpty(args)) { - return new URLCombiner(href, `?${args.join('&')}`).toString(); - } else { - return href; - } - } - /** - * Adds the embed options to the link for the request - * @param href The href the params are to be added to - * @param args params for the query string - * @param linksToFollow links we want to embed in query string if shouldEmbed is true - */ - protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) { - linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { - if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { - const embedString = 'embed=' + String(linkToFollow.name); - // Add the embeds size if given in the FollowLinkConfig.FindListOptions - if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { - args = this.addHrefArg(href, args, - 'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage); - } - // Adds the nested embeds and their size if given - if (isNotEmpty(linkToFollow.linksToFollow)) { - args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow); - } else { - args = this.addHrefArg(href, args, embedString); - } - } - }); - return args; - } - - /** - * Add a new argument to the list of arguments, only if it doesn't already exist in the given href, - * or the current list of arguments - * - * @param href The href the arguments are to be added to - * @param currentArgs The current list of arguments - * @param newArg The new argument to add - * @return The next list of arguments, with newArg included if it wasn't already. - * Note this function will not modify any of the input params. - */ - protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] { - if (href.includes(newArg) || currentArgs.includes(newArg)) { - return [...currentArgs]; - } else { - return [...currentArgs, newArg]; - } - } - - /** - * Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively - * @param embedString embedString so far (recursive) - * @param href The href the params are to be added to - * @param args params for the query string - * @param linksToFollow links we want to embed in query string if shouldEmbed is true - */ - protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig[]): string[] { - let nestEmbed = embedString; - linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { - if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { - nestEmbed = nestEmbed + '/' + String(linkToFollow.name); - // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions - if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { - const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage; - args = this.addHrefArg(href, args, nestedEmbedSize); - } - if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) { - args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow); - } else { - args = this.addHrefArg(href, args, nestEmbed); - } - } - }); - return args; - } - - /** - * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded - * info should be added to the objects - * - * @param options Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - * @return {Observable>>} - * Return an observable that emits object list - */ - findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.findAllByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow - * @param endpoint The base endpoint for the type of object - * @param resourceID The identifier for the object - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - return this.buildHrefFromFindOptions(endpoint + '/' + resourceID, {}, [], ...linksToFollow); - } - - /** - * Create an observable for the HREF of a specific object based on its identifier - * @param resourceID The identifier for the object - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig[]): Observable { - return this.getEndpoint().pipe( - map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); - } - - /** - * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of - * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param id ID of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); - return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * An operator that will call the given function if the incoming RemoteData is stale and - * shouldReRequest is true - * - * @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale - * @param requestFn The function to call if the RemoteData is stale and shouldReRequest is - * true - */ - protected reRequestStaleRemoteData(shouldReRequest: boolean, requestFn: () => Observable>) { - return (source: Observable>): Observable> => { - if (shouldReRequest === true) { - return source.pipe( - tap((remoteData: RemoteData) => { - if (hasValue(remoteData) && remoteData.isStale) { - requestFn(); - } - }) - ); - } else { - return source; - } - }; - } - - /** - * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of - * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param href$ The url of object we want to retrieve. Can be a string or - * an Observable - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByHref(href$: string | Observable, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - const requestHref$ = href$.pipe( - isNotEmptyOperator(), - take(1), - map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)) - ); - - this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - - return 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 - // cached completed object - skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), - this.reRequestStaleRemoteData(reRequestOnStale, () => - this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)) - ); - } - - /** - * Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list - * of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param href$ The url of object we want to retrieve. Can be a string or - * an Observable - * @param findListOptions Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findAllByHref(href$: string | Observable, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - const requestHref$ = href$.pipe( - isNotEmptyOperator(), - take(1), - map((href: string) => this.buildHrefFromFindOptions(href, findListOptions, [], ...linksToFollow)) - ); - - this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - - return 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 - // cached completed object - skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), - this.reRequestStaleRemoteData(reRequestOnStale, () => - this.findAllByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)) - ); - } - - /** - * Create a GET request for the given href, and send it. - * - * @param href$ The url of object we want to retrieve. Can be a string or - * an Observable - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - */ - protected createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable = true): void { - if (isNotEmpty(href$)) { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - href$.pipe( - isNotEmptyOperator(), - take(1) - ).subscribe((href: string) => { - const requestId = this.requestService.generateRequestId(); - const request = new GetRequest(requestId, href); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request, useCachedVersionIfAvailable); - }); - } - } - - /** - * Return object search endpoint by given search method - * - * @param searchMethod The search method for the object - */ - protected getSearchEndpoint(searchMethod: string): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - filter((href: string) => isNotEmpty(href)), - map((href: string) => `${href}/search/${searchMethod}`)); - } - - /** - * Make a new FindListRequest with given search method - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - * @return {Observable>} - * Return an observable that emits response from the server - */ - searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); - - return this.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Send a patch request for a specified object - * @param {T} object The object to send a patch request for - * @param {Operation[]} operations The patch operations to be performed - */ - patch(object: T, operations: Operation[]): Observable> { - const requestId = this.requestService.generateRequestId(); - - const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIDHref(endpoint, object.uuid))); - - hrefObs.pipe( - find((href: string) => hasValue(href)), - ).subscribe((href: string) => { - const request = new PatchRequest(requestId, href, operations); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request); - }); - - return this.rdbService.buildFromRequestUUID(requestId); - } - - createPatchFromCache(object: T): Observable { - const oldVersion$ = this.findByHref(object._links.self.href, true, false); - return oldVersion$.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((oldVersion: T) => this.comparator.diff(oldVersion, object))); - } - - /** - * Send a PUT request for the specified object - * - * @param object The object to send a put request for. - */ - put(object: T): Observable> { - const requestId = this.requestService.generateRequestId(); - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); - const request = new PutRequest(requestId, object._links.self.href, serializedObject); - - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - - this.requestService.send(request); - - return this.rdbService.buildFromRequestUUID(requestId); - } - - /** - * Add a new patch to the object cache - * The patch is derived from the differences between the given object and its version in the object cache - * @param {DSpaceObject} object The given object - */ - update(object: T): Observable> { - return this.createPatchFromCache(object) - .pipe( - mergeMap((operations: Operation[]) => { - if (isNotEmpty(operations)) { - this.objectCache.addPatch(object._links.self.href, operations); - } - return this.findByHref(object._links.self.href, true, true); - } - ) - ); - } - - /** - * Create a new DSpaceObject on the server, and store the response - * in the object cache - * - * @param {CacheableObject} object - * The object to create - * @param {RequestParam[]} params - * Array with additional params to combine with query string - */ - create(object: T, ...params: RequestParam[]): Observable> { - const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.getEndpoint().pipe( - isNotEmptyOperator(), - distinctUntilChanged(), - map((endpoint: string) => this.buildHrefWithParams(endpoint, params)) - ); - - const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object); - - endpoint$.pipe( - take(1) - ).subscribe((endpoint: string) => { - const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject)); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request); - }); - - const result$ = this.rdbService.buildFromRequestUUID(requestId); - - // TODO a dataservice is not the best place to show a notification, - // this should move up to the components that use this method - result$.pipe( - takeWhile((rd: RemoteData) => rd.isLoading, true) - ).subscribe((rd: RemoteData) => { - if (rd.hasFailed) { - this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); - } - }); - - return result$; - } - - /** - * Delete an existing DSpace Object on the server - * @param objectId The id of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - */ - delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { - return this.getIDHrefObs(objectId).pipe( - switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)) - ); - } - - /** - * Delete an existing DSpace Object on the server - * @param href The self link of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - */ - deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { - const requestId = this.requestService.generateRequestId(); - - if (copyVirtualMetadata) { - copyVirtualMetadata.forEach((id) => - href += (href.includes('?') ? '&' : '?') - + 'copyVirtualMetadata=' - + id - ); - } - - const request = new DeleteRequest(requestId, href); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request); - - return this.rdbService.buildFromRequestUUID(requestId); - } - - /** - * Commit current object changes to the server - * @param method The RestRequestMethod for which de server sync buffer should be committed - */ - commitUpdates(method?: RestRequestMethod) { - this.requestService.commit(method); - } - - /** - * Return the links to traverse from the root of the api to the - * endpoint this DataService represents - * - * e.g. if the api root links to 'foo', and the endpoint at 'foo' - * links to 'bar' the linkPath for the BarDataService would be - * 'foo/bar' - */ - getLinkPath(): string { - return this.linkPath; - } -} diff --git a/src/app/core/data/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index fbc07cbb39..992a29e4b8 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -2,7 +2,7 @@ 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 './request.models'; +import { RestRequest } from './rest-request.model'; @Injectable() export class DebugResponseParsingService implements ResponseParsingService { diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 34a619648a..70c45bbc2d 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -2,9 +2,9 @@ import { Injectable } from '@angular/core'; import { compare } from 'fast-json-patch'; import { Operation } from 'fast-json-patch'; import { getClassForType } from '../cache/builders/build-decorators'; -import { TypedObject } from '../cache/object-cache.reducer'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +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 @@ -22,8 +22,8 @@ export class DefaultChangeAnalyzer implements ChangeAnaly * The second object to compare */ diff(object1: T, object2: T): Operation[] { - const serializer1 = new DSpaceSerializer(getClassForType(object1.type)); - const serializer2 = new DSpaceSerializer(getClassForType(object2.type)); + const serializer1 = new DSpaceNotNullSerializer(getClassForType(object1.type)); + const serializer2 = new DSpaceNotNullSerializer(getClassForType(object2.type)); return compare(serializer1.serialize(object1), serializer2.serialize(object2)); } } diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index b79e83a3f3..a621895633 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -3,7 +3,7 @@ import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataMap } from '../shared/metadata.models'; -import { cloneDeep } from 'lodash'; +import cloneDeep from 'lodash/cloneDeep'; /** * A class to determine what differs between two diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts deleted file mode 100644 index 83395d4719..0000000000 --- a/src/app/core/data/dso-redirect-data.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -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 { CoreState } from '../core.reducers'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.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 { Item } from '../shared/item.model'; -import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; - -@Injectable() -export class DsoRedirectDataService extends DataService { - - // Set the default link path to the identifier lookup endpoint. - protected linkPath = 'pid'; - private uuidEndpoint = 'dso'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - private router: Router) { - super(); - } - - setLinkPath(identifierType: IdentifierType) { - // The default 'pid' endpoint for identifiers does not support uuid lookups. - // For uuid lookups we need to change the linkPath. - if (identifierType === IdentifierType.UUID) { - this.linkPath = this.uuidEndpoint; - } - } - - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - // Supporting both identifier (pid) and uuid (dso) endpoints - return this.buildHrefFromFindOptions( endpoint.replace(/\{\?id\}/, `?id=${resourceID}`) - .replace(/\{\?uuid\}/, `?uuid=${resourceID}`), - {}, [], ...linksToFollow); - } - - findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { - this.setLinkPath(identifierType); - return this.findById(id).pipe( - getFirstCompletedRemoteData(), - tap((response) => { - if (response.hasSucceeded) { - const dso = response.payload; - const uuid = dso.uuid; - if (hasValue(uuid)) { - let newRoute = this.getEndpointFromDSOType(response.payload.type); - if (dso.type.startsWith('item')) { - newRoute = getItemPageRoute(dso as Item); - } else if (hasValue(newRoute)) { - newRoute += '/' + uuid; - } - if (hasValue(newRoute)) { - this.router.navigate([newRoute]); - } - } - } - }) - ); - } - // Is there an existing method somewhere else that converts dso type to route? - getEndpointFromDSOType(dsoType: string): string { - // Are there other types to consider? - if (dsoType.startsWith('item')) { - return 'items'; - } else if (dsoType.startsWith('community')) { - return 'communities'; - } else if (dsoType.startsWith('collection')) { - return 'collections'; - } else { - return ''; - } - } -} diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts similarity index 66% rename from src/app/core/data/dso-redirect-data.service.spec.ts rename to src/app/core/data/dso-redirect.service.spec.ts index bcd25487c2..ca064b5608 100644 --- a/src/app/core/data/dso-redirect-data.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -1,22 +1,19 @@ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; 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 { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DsoRedirectDataService } from './dso-redirect-data.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'; -describe('DsoRedirectDataService', () => { +describe('DsoRedirectService', () => { let scheduler: TestScheduler; - let service: DsoRedirectDataService; + let service: DsoRedirectService; let halService: HALEndpointService; let requestService: RequestService; let rdbService: RemoteDataBuildService; @@ -29,10 +26,6 @@ describe('DsoRedirectDataService', () => { const requestHandleURL = `https://rest.api/rest/api/pid/find?id=${encodedHandle}`; const requestUUIDURL = `https://rest.api/rest/api/pid/find?id=${dsoUUID}`; const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; - const store = {} as Store; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; const objectCache = {} as ObjectCacheService; beforeEach(() => { @@ -59,20 +52,16 @@ describe('DsoRedirectDataService', () => { a: remoteData }) }); - service = new DsoRedirectDataService( + service = new DsoRedirectService( requestService, rdbService, - store, objectCache, halService, - notificationsService, - http, - comparator, - router + router, ); }); - describe('findById', () => { + describe('findByIdAndIDType', () => { it('should call HALEndpointService with the path to the pid endpoint', () => { scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE)); scheduler.flush(); @@ -141,7 +130,7 @@ describe('DsoRedirectDataService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(router.navigate).toHaveBeenCalledWith([remoteData.payload.type + 's/' + remoteData.payload.uuid]); + expect(router.navigate).toHaveBeenCalledWith(['/collections/' + remoteData.payload.uuid]); }); it('should navigate to communities route', () => { @@ -150,55 +139,58 @@ describe('DsoRedirectDataService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(router.navigate).toHaveBeenCalledWith(['communities/' + remoteData.payload.uuid]); + expect(router.navigate).toHaveBeenCalledWith(['/communities/' + remoteData.payload.uuid]); }); }); - describe('getIDHref', () => { - it('should return endpoint', () => { - const result = (service as any).getIDHref(pidLink, dsoUUID); - expect(result).toEqual(requestUUIDURL); - }); + describe('DataService', () => { // todo: should only test the id/uuid interpolation thingy + describe('getIDHref', () => { // todo: should be able to move this up to IdentifiableDataService? + it('should return endpoint', () => { + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID); + expect(result).toEqual(requestUUIDURL); + }); - it('should include single linksToFollow as embed', () => { - const expected = `${requestUUIDURL}&embed=bundles`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles')); - expect(result).toEqual(expected); - }); + it('should include single linksToFollow as embed', () => { + const expected = `${requestUUIDURL}&embed=bundles`; + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles')); + expect(result).toEqual(expected); + }); - it('should include multiple linksToFollow as embed', () => { - const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); - expect(result).toEqual(expected); - }); + it('should include multiple linksToFollow as embed', () => { + const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); + expect(result).toEqual(expected); + }); - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${requestUUIDURL}&embed=templateItemOf`; - const result = (service as any).getIDHref( - pidLink, - dsoUUID, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ); - expect(result).toEqual(expected); - }); + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${requestUUIDURL}&embed=templateItemOf`; + const result = (service as any).dataService.getIDHref( + pidLink, + dsoUUID, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf'), + ); + expect(result).toEqual(expected); + }); - it('should include nested linksToFollow 3lvl', () => { - const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref( - pidLink, - dsoUUID, - followLink('owningCollection', - {}, - followLink('itemtemplate', + it('should include nested linksToFollow 3lvl', () => { + const expected = `${requestUUIDURL}&embed=owningCollection${EMBED_SEPARATOR}itemtemplate${EMBED_SEPARATOR}relationships`; + const result = (service as any).dataService.getIDHref( + pidLink, + dsoUUID, + followLink( + 'owningCollection', {}, - followLink('relationships') - ) - ) - ); - expect(result).toEqual(expected); + followLink( + 'itemtemplate', + {}, + followLink('relationships'), + ), + ), + ); + expect(result).toEqual(expected); + }); }); }); - }); diff --git a/src/app/core/data/dso-redirect.service.ts b/src/app/core/data/dso-redirect.service.ts new file mode 100644 index 0000000000..81ce678e43 --- /dev/null +++ b/src/app/core/data/dso-redirect.service.ts @@ -0,0 +1,104 @@ +/** + * 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 max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +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 { 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'; + +const ID_ENDPOINT = 'pid'; +const UUID_ENDPOINT = 'dso'; + +/** + * A data service to retrieve DSpaceObjects by persistent identifier or UUID. + * Doesn't define a constant {@link linkPath} but switches between two endpoints on demand: + * {@link setLinkPath} must be called before each request. + */ +class DsoByIdOrUUIDDataService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super( + undefined, requestService, rdbService, objectCache, halService, undefined, + // interpolate id/uuid as query parameter + (endpoint: string, resourceID: string): string => { + return endpoint.replace(/{\?id}/, `?id=${resourceID}`) + .replace(/{\?uuid}/, `?uuid=${resourceID}`); + }, + ); + } + + /** + * The default 'pid' endpoint for identifiers does not support uuid lookups. + * For uuid lookups we need to change the linkPath. + * @param identifierType + */ + setLinkPath(identifierType: IdentifierType) { + if (identifierType === IdentifierType.UUID) { + this.linkPath = UUID_ENDPOINT; + } else { + this.linkPath = ID_ENDPOINT; + } + } +} + +/** + * A service to handle redirects from identifier paths to DSO path + * e.g.: redirect from /handle/... to /items/... + */ +@Injectable() +export class DsoRedirectService { + private dataService: DsoByIdOrUUIDDataService; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + private router: Router, + ) { + this.dataService = new DsoByIdOrUUIDDataService(requestService, rdbService, objectCache, halService); + } + + /** + * Retrieve a DSpaceObject by + * @param id the identifier of the object to retrieve + * @param identifierType the type of the given identifier (defaults to UUID) + */ + findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { + this.dataService.setLinkPath(identifierType); + return this.dataService.findById(id).pipe( + getFirstCompletedRemoteData(), + tap((response) => { + if (response.hasSucceeded) { + const dso = response.payload; + if (hasValue(dso.uuid)) { + let newRoute = getDSORoute(dso); + if (hasValue(newRoute)) { + this.router.navigate([newRoute]); + } + } + } + }) + ); + } +} diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 7dde1f53a1..fd5a22fae9 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -3,12 +3,12 @@ import { Injectable } from '@angular/core'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; -import { RestRequest } from './request.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 { RestRequest } from './rest-request.model'; @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { 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 4b3fafa73a..0f167ea47e 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -7,8 +7,6 @@ import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { DSpaceObjectDataService } from './dspace-object-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -42,18 +40,12 @@ describe('DSpaceObjectDataService', () => { }) }); objectCache = {} as ObjectCacheService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; service = new DSpaceObjectDataService( requestService, rdbService, objectCache, halService, - notificationsService, - http, - comparator ); }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index eb230e2f54..2ad024133c 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,107 +1,28 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { FindListOptions } from './request.models'; -import { PaginatedList } from './paginated-list.model'; - -/* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { - protected linkPath = 'dso'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { - super(); - } - - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - return this.buildHrefFromFindOptions( endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`), - {}, [], ...linksToFollow); - } -} +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(DSPACE_OBJECT) -export class DSpaceObjectDataService { - protected linkPath = 'dso'; - private dataService: DataServiceImpl; - +export class DSpaceObjectDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super( + 'dso', requestService, rdbService, objectCache, halService, undefined, + // interpolate uuid as query parameter + (endpoint: string, resourceID: string): string => { + return endpoint.replace(/{\?uuid}/, `?uuid=${resourceID}`); + }, + ); } - - /** - * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the object - * @param id ID of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - - } - /** - * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the object - * @param href The url of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the object - * @param href The url of object we want to retrieve - * @param findListOptions Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - } -/* tslint:enable:max-classes-per-file */ 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 2fda0bf40a..500afc4aff 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -1,13 +1,12 @@ +/* eslint-disable max-classes-per-file */ import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { CacheableObject } from '../cache/object-cache.reducer'; 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 { RestRequest } from './request.models'; import { environment } from '../../../environments/environment'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -17,8 +16,9 @@ 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 { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; -/* tslint:disable:max-classes-per-file */ /** * Return true if obj has a value for `_links.self` @@ -271,4 +271,3 @@ export class DspaceRestResponseParsingService implements ResponseParsingService return statusCode >= 200 && statusCode < 300; } } -/* tslint:enable:max-classes-per-file */ 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 1a81deaea0..728714876c 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -7,12 +7,12 @@ import { import { hasValue } from '../../shared/empty.util'; import { getClassForType } from '../cache/builders/build-decorators'; import { GenericConstructor } from '../shared/generic-constructor'; -import { RestRequest } from './request.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { environment } from '../../../environments/environment'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; /** * ResponseParsingService able to deal with HAL Endpoints that are only needed as steps diff --git a/src/app/core/data/entity-type-data.service.spec.ts b/src/app/core/data/entity-type-data.service.spec.ts new file mode 100644 index 0000000000..cf164adb84 --- /dev/null +++ b/src/app/core/data/entity-type-data.service.spec.ts @@ -0,0 +1,19 @@ +/** + * 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 { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { EntityTypeDataService } from './entity-type-data.service'; + +describe('EntityTypeDataService', () => { + describe('composition', () => { + const initService = () => new EntityTypeDataService(null, null, null, null, null); + testFindAllDataImplementation(initService); + testSearchDataImplementation(initService); + }); +}); diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type-data.service.ts similarity index 59% rename from src/app/core/data/entity-type.service.ts rename to src/app/core/data/entity-type-data.service.ts index 40b9373107..4020ff638d 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type-data.service.ts @@ -1,16 +1,9 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; -import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { RemoteData } from './remote-data'; @@ -18,26 +11,31 @@ import { RelationshipType } from '../shared/item-relationships/relationship-type import { PaginatedList } from './paginated-list.model'; import { ItemType } from '../shared/item-relationships/item-type.model'; import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { RelationshipTypeService } from './relationship-type.service'; +import { RelationshipTypeDataService } from './relationship-type-data.service'; +import { FindListOptions } from './find-list-options.model'; +import { BaseDataService } from './base/base-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; /** * Service handling all ItemType requests */ @Injectable() -export class EntityTypeService extends DataService { +export class EntityTypeDataService extends BaseDataService implements FindAllData, SearchData { + private findAllData: FindAllData; + private searchData: SearchDataImpl; - protected linkPath = 'entitytypes'; + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected relationshipTypeService: RelationshipTypeDataService, + ) { + super('entitytypes', requestService, rdbService, objectCache, halService); - constructor(protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, - protected objectCache: ObjectCacheService, - protected notificationsService: NotificationsService, - protected relationshipTypeService: RelationshipTypeService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } getBrowseEndpoint(options, linkPath?: string): Observable { @@ -147,7 +145,7 @@ export class EntityTypeService extends DataService { */ getEntityTypeRelationships(entityTypeId: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getRelationshipTypesEndpoint(entityTypeId); - return this.relationshipTypeService.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.relationshipTypeService.findListByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -158,7 +156,43 @@ export class EntityTypeService extends DataService { return this.halService.getEndpoint(this.linkPath).pipe( take(1), switchMap((endPoint: string) => - this.findByHref(endPoint + '/label/' + label)) + this.findByHref(endPoint + '/label/' + label)), ); } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index 2407249615..afd4927103 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -1,7 +1,6 @@ import { RequestService } from './request.service'; import { EpersonRegistrationService } from './eperson-registration.service'; import { RestResponse } from '../cache/response.models'; -import { RequestEntry } from './request.reducer'; import { cold } from 'jasmine-marbles'; import { PostRequest } from './request.models'; import { Registration } from '../shared/registration.model'; @@ -9,6 +8,9 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic 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 { HttpOptions } from '../dspace-rest/dspace-rest.service'; describe('EpersonRegistrationService', () => { let testScheduler; @@ -79,8 +81,23 @@ describe('EpersonRegistrationService', () => { it('should send an email registration', () => { const expected = service.registerEmail('test@mail.org'); + let headers = new HttpHeaders(); + const options: HttpOptions = Object.create({}); + options.headers = headers; - expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration)); + expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); + expect(expected).toBeObservable(cold('(a|)', { a: rd })); + }); + + it('should send an email registration with captcha', () => { + + const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken'); + let headers = new HttpHeaders(); + const options: HttpOptions = Object.create({}); + headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken'); + options.headers = headers; + + expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); expect(expected).toBeObservable(cold('(a|)', { a: rd })); }); }); @@ -90,15 +107,17 @@ describe('EpersonRegistrationService', () => { const expected = service.searchByToken('test-token'); expect(expected).toBeObservable(cold('(a|)', { - a: Object.assign(new Registration(), { - email: registrationWithUser.email, - token: 'test-token', - user: registrationWithUser.user + a: jasmine.objectContaining({ + payload: Object.assign(new Registration(), { + email: registrationWithUser.email, + token: 'test-token', + user: registrationWithUser.user + }) }) })); }); - // tslint:disable:no-shadowed-variable + /* eslint-disable @typescript-eslint/no-shadow */ it('should use cached responses and /registrations/search/findByToken?', () => { testScheduler.run(({ cold, expectObservable }) => { rdbService.buildSingle.and.returnValue(cold('a', { a: rd })); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index adf01b0ce9..bfbecdaecb 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -3,21 +3,21 @@ 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, skipWhile } from 'rxjs/operators'; +import { filter, find, map } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { Registration } from '../shared/registration.model'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../shared/operators'; +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 { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HttpHeaders } from '@angular/common/http'; -@Injectable( - { - providedIn: 'root', - } -) +@Injectable({ + providedIn: 'root', +}) /** * Service that will register a new email address and request a token */ @@ -53,8 +53,9 @@ export class EpersonRegistrationService { /** * Register a new email address * @param email + * @param captchaToken the value of x-recaptcha-token header */ - registerEmail(email: string): Observable> { + registerEmail(email: string, captchaToken: string = null): Observable> { const registration = new Registration(); registration.email = email; @@ -62,10 +63,17 @@ export class EpersonRegistrationService { const href$ = this.getRegistrationEndpoint(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + if (captchaToken) { + headers = headers.append('x-recaptcha-token', captchaToken); + } + options.headers = headers; + href$.pipe( find((href: string) => hasValue(href)), map((href: string) => { - const request = new PostRequest(requestId, href, registration); + const request = new PostRequest(requestId, href, registration, options); this.requestService.send(request); }) ).subscribe(); @@ -79,7 +87,7 @@ export class EpersonRegistrationService { * Search a registration based on the provided token * @param token */ - searchByToken(token: string): Observable { + searchByToken(token: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getTokenSearchEndpoint(token).pipe( @@ -97,15 +105,14 @@ export class EpersonRegistrationService { }); return this.rdbService.buildSingle(href$).pipe( - skipWhile((rd: RemoteData) => rd.isStale), - getFirstSucceededRemoteData(), - map((restResponse: RemoteData) => { - return Object.assign(new Registration(), { - email: restResponse.payload.email, token: token, user: restResponse.payload.user - }); - }), + map((rd) => { + if (rd.hasSucceeded && hasValue(rd.payload)) { + return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) }); + } else { + return rd; + } + }) ); - } } diff --git a/src/app/core/data/external-source.service.spec.ts b/src/app/core/data/external-source-data.service.spec.ts similarity index 80% rename from src/app/core/data/external-source.service.spec.ts rename to src/app/core/data/external-source-data.service.spec.ts index 59226197d1..cdbdbaa006 100644 --- a/src/app/core/data/external-source.service.spec.ts +++ b/src/app/core/data/external-source-data.service.spec.ts @@ -1,12 +1,13 @@ -import { ExternalSourceService } from './external-source.service'; +import { ExternalSourceDataService } from './external-source-data.service'; 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'; describe('ExternalSourceService', () => { - let service: ExternalSourceService; + let service: ExternalSourceDataService; let requestService; let rdbService; @@ -48,15 +49,20 @@ describe('ExternalSourceService', () => { buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) }); halService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf('external-sources-REST-endpoint') + getEndpoint: observableOf('external-sources-REST-endpoint'), }); - service = new ExternalSourceService(requestService, rdbService, undefined, undefined, halService, undefined, undefined, undefined); + service = new ExternalSourceDataService(requestService, rdbService, undefined, halService); } beforeEach(() => { init(); }); + describe('composition', () => { + const initService = () => new ExternalSourceDataService(null, null, null, null); + testSearchDataImplementation(initService); + }); + describe('getExternalSourceEntries', () => { let result; diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source-data.service.ts similarity index 62% rename from src/app/core/data/external-source.service.ts rename to src/app/core/data/external-source-data.service.ts index d2fc9e6d96..c0552aeaec 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source-data.service.ts @@ -1,15 +1,9 @@ import { Injectable } from '@angular/core'; -import { DataService } from './data.service'; import { ExternalSource } from '../shared/external-source.model'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; @@ -17,26 +11,27 @@ import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; /** * A service handling all external source requests */ @Injectable() -export class ExternalSourceService extends DataService { - protected linkPath = 'externalsources'; +export class ExternalSourceDataService extends IdentifiableDataService implements SearchData { + private searchData: SearchData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + ) { + super('externalsources', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -75,10 +70,28 @@ export class ExternalSourceService extends DataService { isNotEmptyOperator(), distinctUntilChanged(), map((endpoint: string) => hasValue(searchOptions) ? searchOptions.toRestUrl(endpoint) : endpoint), - take(1) + take(1), ); // TODO create a dedicated ExternalSourceEntryDataService and move this entire method to it. Then the "as any"s won't be necessary - return this.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any; + return this.findListByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any; + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } 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 8c24bd61d9..3e4493c32b 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -3,9 +3,9 @@ import { SearchFilterConfig } from '../../shared/search/models/search-filter-con import { ParsedResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { RestRequest } from './request.models'; 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() export class FacetConfigResponseParsingService extends DspaceRestResponseParsingService { 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 12a2d4ba8c..0911ed5073 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -3,9 +3,9 @@ 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 { RestRequest } from './request.models'; import { FacetValues } from '../../shared/search/models/facet-values.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { RestRequest } from './rest-request.model'; @Injectable() export class FacetValueResponseParsingService extends DspaceRestResponseParsingService { 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 01bd23d7c7..ae44d590a4 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,10 +1,8 @@ import { AuthorizationDataService } from './authorization-data.service'; import { SiteDataService } from '../site-data.service'; -import { AuthService } from '../../auth/auth.service'; import { Site } from '../../shared/site.model'; import { EPerson } from '../../eperson/models/eperson.model'; -import { of as observableOf } from 'rxjs'; -import { FindListOptions } from '../request.models'; +import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { FeatureID } from './feature-id'; import { hasValue } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; @@ -12,11 +10,14 @@ 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'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; let siteService: SiteDataService; - let authService: AuthService; + let objectCache; let site: Site; let ePerson: EPerson; @@ -37,13 +38,10 @@ describe('AuthorizationDataService', () => { uuid: 'test-eperson' }); siteService = jasmine.createSpyObj('siteService', { - find: observableOf(site) + find: observableOf(site), }); - authService = { - isAuthenticated: () => observableOf(true), - getAuthenticatedUserFromStore: () => observableOf(ePerson) - } as AuthService; - service = new AuthorizationDataService(requestService, undefined, undefined, undefined, undefined, undefined, undefined, undefined, authService, siteService); + objectCache = getMockObjectCacheService(); + service = new AuthorizationDataService(requestService, undefined, objectCache, undefined, siteService); } beforeEach(() => { @@ -51,6 +49,11 @@ describe('AuthorizationDataService', () => { spyOn(service, 'searchBy').and.returnValue(observableOf(undefined)); }); + describe('composition', () => { + const initService = () => new AuthorizationDataService(null, null, null, null, null); + testSearchDataImplementation(initService); + }); + it('should call setStaleByHrefSubstring method', () => { service.invalidateAuthorizationsRequestCache(); expect((service as any).requestService.setStaleByHrefSubstring).toHaveBeenCalledWith((service as any).linkPath); @@ -110,6 +113,43 @@ describe('AuthorizationDataService', () => { expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf), true, true); }); }); + + describe('dependencies', () => { + let addDependencySpy; + + beforeEach(() => { + (service.searchBy as any).and.returnValue(observableOf('searchBy RD$')); + addDependencySpy = spyOn(service as any, 'addDependency'); + }); + + it('should add a dependency on the objectUrl', (done) => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe('searchBy RD$'); + expect(dependsOn).toBe('object-href'); + }); + }); + + service.searchByObject(FeatureID.AdministratorOf, 'object-href').subscribe(() => { + expect(addDependencySpy).toHaveBeenCalled(); + done(); + }); + }); + + it('should add a dependency on the Site object if no objectUrl is given', (done) => { + addDependencySpy.and.callFake((object$: Observable, dependsOn$: Observable) => { + observableCombineLatest([object$, dependsOn$]).subscribe(([object, dependsOn]) => { + expect(object).toBe('searchBy RD$'); + expect(dependsOn).toBe('test-site-href'); + }); + }); + + service.searchByObject(FeatureID.AdministratorOf).subscribe(() => { + expect(addDependencySpy).toHaveBeenCalled(); + done(); + }); + }); + }); }); describe('isAuthorized', () => { 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 b9812cdbb3..c43d335234 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -1,54 +1,48 @@ import { Observable, of as observableOf } from 'rxjs'; import { Injectable } from '@angular/core'; import { AUTHORIZATION } from '../../shared/authorization.resource-type'; -import { dataService } from '../../cache/builders/build-decorators'; -import { DataService } from '../data.service'; import { Authorization } from '../../shared/authorization.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; -import { AuthService } from '../../auth/auth.service'; import { SiteDataService } from '../site-data.service'; -import { FindListOptions } from '../request.models'; 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 { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; import { AuthorizationSearchParams } from './authorization-search-params'; -import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; +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) -export class AuthorizationDataService extends DataService { +export class AuthorizationDataService extends BaseDataService implements SearchData { protected linkPath = 'authorizations'; protected searchByObjectPath = 'object'; + private searchData: SearchDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - protected authService: AuthService, - protected siteService: SiteDataService + protected siteService: SiteDataService, ) { - super(); + super('authorizations', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -60,14 +54,18 @@ export class AuthorizationDataService extends DataService { /** * Checks if an {@link EPerson} (or anonymous) has access to a specific object within a {@link Feature} - * @param objectUrl URL to the object to search {@link Authorization}s for. - * If not provided, the repository's {@link Site} will be used. - * @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for. - * If not provided, the UUID of the currently authenticated {@link EPerson} will be used. - * @param featureId ID of the {@link Feature} to check {@link Authorization} for + * @param objectUrl URL to the object to search {@link Authorization}s for. + * If not provided, the repository's {@link Site} will be used. + * @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for. + * If not provided, the UUID of the currently authenticated {@link EPerson} will be used. + * @param featureId ID of the {@link Feature} to check {@link Authorization} for + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale */ - isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable { - return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, true, true, followLink('feature')).pipe( + isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { + return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, useCachedVersionIfAvailable, reRequestOnStale, followLink('feature')).pipe( getFirstCompletedRemoteData(), map((authorizationRD) => { if (authorizationRD.statusCode !== 401 && hasValue(authorizationRD.payload) && isNotEmpty(authorizationRD.payload.page)) { @@ -98,12 +96,28 @@ export class AuthorizationDataService extends DataService { * {@link HALLink}s should be automatically resolved */ searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( - addSiteObjectUrlIfEmpty(this.siteService), + const objectUrl$ = observableOf(objectUrl).pipe( + switchMap((url) => { + if (hasNoValue(url)) { + return this.siteService.find().pipe( + map((site) => site.self) + ); + } else { + return observableOf(url); + } + }), + ); + + const out$ = objectUrl$.pipe( + 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$); + + return out$; } /** @@ -126,7 +140,25 @@ export class AuthorizationDataService extends DataService { params.push(new RequestParam('eperson', ePersonUuid)); } return Object.assign(new FindListOptions(), options, { - searchParams: [...params] + searchParams: [...params], }); } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } 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 3a6cf745c9..b909640ea6 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 @@ -2,9 +2,9 @@ import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTr import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/operators'; import { switchMap } from 'rxjs/operators'; import { AuthService } from '../../../auth/auth.service'; +import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/authorized.operators'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user @@ -19,7 +19,7 @@ export abstract class SomeFeatureAuthorizationGuard implements CanActivate { /** * True when user has authorization rights for the feature and object provided - * Redirect the user to the unauthorized page when he/she's not authorized for the given feature + * Redirect the user to the unauthorized page when they are not authorized for the given feature */ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( 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 12be6f8452..eda8791153 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -1,36 +1,27 @@ import { Injectable } from '@angular/core'; import { FEATURE } from '../../shared/feature.resource-type'; -import { dataService } from '../../cache/builders/build-decorators'; -import { DataService } from '../data.service'; import { Feature } from '../../shared/feature.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { BaseDataService } from '../base/base-data.service'; +import { dataService } from '../base/data-service.decorator'; /** * A service to retrieve {@link Feature}s from the REST API */ @Injectable() @dataService(FEATURE) -export class FeatureDataService extends DataService { +export class FeatureDataService extends BaseDataService { protected linkPath = 'features'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer ) { - super(); + super('features', requestService, rdbService, objectCache, halService); } } diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 029c75d9cb..3cb18bf515 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -28,4 +28,6 @@ export enum FeatureID { CanCreateVersion = 'canCreateVersion', CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', + CanClaimItem = 'canClaimItem', + CanSynchronizeWithORCID = 'canSynchronizeWithORCID' } 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 7a2ff7962d..da7a21c488 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,10 +1,10 @@ import { Injectable } from '@angular/core'; import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.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 { RestRequest } from './rest-request.model'; /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a discovery query (string) @@ -16,7 +16,8 @@ export class FilteredDiscoveryPageResponseParsingService extends BaseResponsePar toCache = false; constructor( protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(); } /** diff --git a/src/app/core/data/find-list-options.model.ts b/src/app/core/data/find-list-options.model.ts new file mode 100644 index 0000000000..dc567d4b53 --- /dev/null +++ b/src/app/core/data/find-list-options.model.ts @@ -0,0 +1,15 @@ +import { SortOptions } from '../cache/models/sort-options.model'; +import { RequestParam } from '../cache/models/request-param.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; +} 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 dd4be83203..bf7d2890ea 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,8 @@ 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 './request.models'; -import { DataService } from './data.service'; +import { FindListOptions } from './find-list-options.model'; +import { BaseDataService } from './base/base-data.service'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; @@ -15,12 +15,12 @@ describe(`HrefOnlyDataService`, () => { href = 'https://rest.api/server/api/core/items/de7fa215-4a25-43a7-a4d7-17534a09fdfc'; followLinks = [ followLink('link1'), followLink('link2') ]; findListOptions = new FindListOptions(); - service = new HrefOnlyDataService(null, null, null, null, null, null, null, null); + service = new HrefOnlyDataService(null, null, null, null); }); it(`should instantiate a private DataService`, () => { expect((service as any).dataService).toBeDefined(); - expect((service as any).dataService).toBeInstanceOf(DataService); + expect((service as any).dataService).toBeInstanceOf(BaseDataService); }); describe(`findByHref`, () => { @@ -28,7 +28,7 @@ describe(`HrefOnlyDataService`, () => { spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); }); - it(`should delegate to findByHref on the internal DataService`, () => { + it(`should forward to findByHref on the internal DataService`, () => { service.findByHref(href, false, false, ...followLinks); expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); }); @@ -48,33 +48,33 @@ describe(`HrefOnlyDataService`, () => { }); }); - describe(`findAllByHref`, () => { + describe(`findListByHref`, () => { beforeEach(() => { - spy = spyOn((service as any).dataService, 'findAllByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); }); - it(`should delegate to findAllByHref on the internal DataService`, () => { - service.findAllByHref(href, findListOptions, false, false, ...followLinks); + 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 findAllByHref on the internal DataService with findListOptions = {}`, () => { - service.findAllByHref(href); + 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 findAllByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findAllByHref(href); + 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 findAllByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findAllByHref(href); + 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 b1bc14ec6f..0a765de101 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,42 +1,20 @@ -import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; -import { FindListOptions } from './request.models'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; 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/object-cache.reducer'; - -/* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { - // linkPath isn't used if we're only searching by href. - protected linkPath = undefined; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +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'; /** * A DataService with only findByHref methods. Its purpose is to be used for resources that don't @@ -44,26 +22,37 @@ class DataServiceImpl extends DataService { * for their links to be resolved by the LinkService. * * an @dataService annotation can be added for any number of these resource types + * + * + * Additionally, this service may be used to retrieve objects by `href` regardless of their type + * For example + * ``` + * const items$: Observable>> = hrefOnlyDataService.findListByHref(href); + * const sites$: Observable>> = hrefOnlyDataService.findListByHref(href); + * ``` + * This means we cannot extend from {@link BaseDataService} directly because the method signatures would not match. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(VOCABULARY_ENTRY) @dataService(ITEM_TYPE) @dataService(LICENSE) -export class HrefOnlyDataService { - private dataService: DataServiceImpl; +export class HrefOnlyDataService implements HALDataService { + /** + * Works with a {@link BaseDataService} internally, but only exposes two of its methods + * with altered signatures to (optionally) constrain the arbitrary return type. + * @private + */ + private dataService: BaseDataService; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, store, objectCache, halService, notificationsService, http, comparator); + ) { + this.dataService = new BaseDataService(undefined, requestService, rdbService, objectCache, halService); } /** @@ -93,7 +82,7 @@ export class HrefOnlyDataService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved */ - findAllByHref(href: string | Observable, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + findListByHref(href: string | Observable, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.findListByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 26a6b52cc3..2c20ed0fb6 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -8,18 +8,23 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { BrowseService } from '../browse/browse.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; -import { CoreState } from '../core.reducers'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, FindListOptions, PostRequest } from './request.models'; -import { RequestEntry } from './request.reducer'; +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 { testCreateDataImplementation } from './base/create-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('ItemDataService', () => { let scheduler: TestScheduler; let service: ItemDataService; - let bs: BrowseService; + let browseService: BrowseService; const requestService = Object.assign(getMockRequestService(), { generateRequestId(): string { return scopeID; @@ -35,13 +40,11 @@ describe('ItemDataService', () => { }) as RequestService; const rdbService = getMockRemoteDataBuildService(); - const itemEndpoint = 'https://rest.api/core/items'; + const itemEndpoint = 'https://rest.api/core'; const store = {} as Store; const objectCache = {} as ObjectCacheService; - const halEndpointService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf(itemEndpoint) - }); + const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { findByHref: {} }); @@ -78,24 +81,29 @@ describe('ItemDataService', () => { return new ItemDataService( requestService, rdbService, - store, - bs, objectCache, halEndpointService, notificationsService, - http, comparator, - bundleService + browseService, + bundleService, ); } + describe('composition', () => { + const initService = () => new ItemDataService(null, null, null, null, null, null, null, null); + testCreateDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); }); it('should return the endpoint to fetch Items within the given scope and starting with the given string', () => { - bs = initMockBrowseService(true); + browseService = initMockBrowseService(true); service = initTestService(); const result = service.getBrowseEndpoint(options); @@ -106,7 +114,7 @@ describe('ItemDataService', () => { describe('if the dc.date.issue browse isn\'t configured for items', () => { beforeEach(() => { - bs = initMockBrowseService(false); + browseService = initMockBrowseService(false); service = initTestService(); }); it('should throw an error', () => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index a8d380124e..09268a0282 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,29 +1,31 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +/** + * 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 max-classes-per-file */ +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; 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 { BrowseService } from '../browse/browse.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Collection } from '../shared/collection.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; -import { sendRequest } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; - -import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models'; +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'; @@ -34,25 +36,44 @@ 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 { 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 { 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'; -@Injectable() -@dataService(ITEM) -export class ItemDataService extends DataService { - protected linkPath = 'items'; +/** + * An abstract service for CRUD operations on Items + * Doesn't specify an endpoint because multiple endpoints support Item-like functionality (e.g. items, itemtemplates) + * Extend this class to implement data services for Items + */ +export abstract class BaseItemDataService extends IdentifiableDataService implements CreateData, PatchData, DeleteData { + private createData: CreateData; + private patchData: PatchData; + private deleteData: DeleteData; - constructor( + protected constructor( + protected linkPath, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, protected comparator: DSOChangeAnalyzer, - protected bundleService: BundleDataService + protected browseService: BrowseService, + protected bundleService: BundleDataService, + protected constructIdEndpoint: ConstructIdEndpoint = (endpoint, resourceID) => `${endpoint}/${resourceID}`, ) { - super(); + super(linkPath, requestService, rdbService, objectCache, halService, undefined, constructIdEndpoint); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -67,10 +88,11 @@ export class ItemDataService extends DataService { if (options.sort && options.sort.field) { field = options.sort.field; } - return this.bs.getBrowseURLFor(field, linkPath).pipe( + return this.browseService.getBrowseURLFor(field, linkPath).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()), - distinctUntilChanged(),); + distinctUntilChanged(), + ); } /** @@ -82,7 +104,7 @@ export class ItemDataService extends DataService { public getMappedCollectionsEndpoint(itemId: string, collectionId?: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`) + map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`), ); } @@ -217,7 +239,7 @@ export class ItemDataService extends DataService { public getMoveItemEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection`) + map((endpoint: string) => `${endpoint}/owningCollection`), ); } @@ -297,4 +319,93 @@ export class ItemDataService extends DataService { this.requestService.setStaleByHrefSubstring('item/' + itemUUID); } + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: Item, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: Item): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: Item): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + public create(object: Item, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + +} + +/** + * A service for CRUD operations on Items + */ +@Injectable() +@dataService(ITEM) +export class ItemDataService extends BaseItemDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: DSOChangeAnalyzer, + protected browseService: BrowseService, + protected bundleService: BundleDataService, + ) { + super('items', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); + } } 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 0d99ca5cd4..a5d1872510 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -35,7 +35,7 @@ describe('ItemRequestDataService', () => { getEndpoint: observableOf(restApiEndpoint), }); - service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null); + service = new ItemRequestDataService(requestService, rdbService, null, halService); }); describe('requestACopy', () => { diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 41ad19211a..ff6025f7ac 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -3,45 +3,33 @@ 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, sendRequest } from '../shared/operators'; +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 { DataService } from './data.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { HttpHeaders } from '@angular/common/http'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { sendRequest } from '../shared/request.operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint */ -@Injectable( - { - providedIn: 'root', - } -) -export class ItemRequestDataService extends DataService { - - protected linkPath = 'itemrequests'; - +@Injectable({ + providedIn: 'root', +}) +export class ItemRequestDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, ) { - super(); + super('itemrequests', requestService, rdbService, objectCache, halService); } getItemRequestEndpoint(): Observable { @@ -123,9 +111,9 @@ export class ItemRequestDataService extends DataService { suggestOpenAccess, }), options); }), - sendRequest(this.requestService)).subscribe(); + sendRequest(this.requestService), + ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); } - } 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 1458527506..16cf0dbd99 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -1,24 +1,27 @@ import { ItemTemplateDataService } from './item-template-data.service'; -import { RestRequest } from './request.models'; -import { RequestEntry } from './request.reducer'; 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 { CoreState } from '../core.reducers'; import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; 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 { CoreState } from '../core-state.model'; +import { RequestEntry } from './request-entry.model'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import createSpyObj = jasmine.createSpyObj; describe('ItemTemplateDataService', () => { let service: ItemTemplateDataService; - let itemService: any; + let byCollection: any; const item = new Item(); const collectionEndpoint = 'https://rest.api/core/collections/4af28e99-6a9c-4036-a199-e1b587046d39'; @@ -47,14 +50,14 @@ describe('ItemTemplateDataService', () => { } as RequestService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; - const bs = {} as BrowseService; + const browseService = {} as BrowseService; const objectCache = { getObjectBySelfLink(self) { return observableOf({}); }, addPatch(self, operations) { // Do nothing - } + }, } as any; const halEndpointService = { getEndpoint(linkPath: string): Observable { @@ -62,7 +65,6 @@ describe('ItemTemplateDataService', () => { } } as HALEndpointService; const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; const comparator = { diff(first, second) { return [{}]; @@ -78,60 +80,68 @@ describe('ItemTemplateDataService', () => { service = new ItemTemplateDataService( requestService, rdbService, - store, - bs, objectCache, halEndpointService, notificationsService, - http, comparator, + browseService, undefined, - collectionService + collectionService, ); - itemService = (service as any).dataService; + byCollection = (service as any).byCollection; } beforeEach(() => { initTestService(); }); - describe('commitUpdates', () => { - it('should call commitUpdates on the item service implementation', () => { - spyOn(itemService, 'commitUpdates'); - service.commitUpdates(); - expect(itemService.commitUpdates).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should call update on the item service implementation', () => { - spyOn(itemService, 'update'); - service.update(item); - expect(itemService.update).toHaveBeenCalled(); - }); + describe('composition', () => { + const initService = () => new ItemTemplateDataService(null, null, null, null, null, null, null, null, null); + testCreateDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); }); describe('findByCollectionID', () => { - it('should call findByCollectionID on the item service implementation', () => { - spyOn(itemService, 'findByCollectionID'); + it('should call findByCollectionID on the collection-based data service', () => { + spyOn(byCollection, 'findById'); service.findByCollectionID(scopeID); - expect(itemService.findByCollectionID).toHaveBeenCalled(); + expect(byCollection.findById).toHaveBeenCalled(); }); }); - describe('create', () => { - it('should call createTemplate on the item service implementation', () => { - spyOn(itemService, 'createTemplate'); - service.create(item, scopeID); - expect(itemService.createTemplate).toHaveBeenCalled(); + describe('createByCollectionID', () => { + it('should call createTemplate on the collection-based data service', () => { + spyOn(byCollection, 'createTemplate'); + service.createByCollectionID(item, scopeID); + expect(byCollection.createTemplate).toHaveBeenCalledWith(item, scopeID); }); }); - describe('deleteByCollectionID', () => { - it('should call deleteByCollectionID on the item service implementation', () => { - spyOn(itemService, 'deleteByCollectionID'); - service.deleteByCollectionID(item, scopeID); - expect(itemService.deleteByCollectionID).toHaveBeenCalled(); + describe('byCollection', () => { + beforeEach(() => { + byCollection.createData = createSpyObj('createData', { + createOnEndpoint: 'TEST createOnEndpoint', + }); + }); + + describe('getIDHrefObs', () => { + it('should point to the Item template of a given Collection', () => { + expect(byCollection.getIDHrefObs(scopeID)).toBeObservable(cold('a', { a: jasmine.stringMatching(`/collections/${scopeID}/itemtemplate`) })); + }); + }); + + describe('createTemplate', () => { + it('should forward to CreateDataImpl.createOnEndpoint', () => { + spyOn(byCollection, 'getIDHrefObs').and.returnValue('TEST getIDHrefObs'); + + const out = byCollection.createTemplate(item, scopeID); + + expect(byCollection.getIDHrefObs).toHaveBeenCalledWith(scopeID); + expect(byCollection.createData.createOnEndpoint).toHaveBeenCalledWith(item, 'TEST getIDHrefObs'); + expect(out).toBe('TEST createOnEndpoint'); + }); }); }); }); + diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index 19e6941385..634c966dba 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,147 +1,62 @@ +/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { ItemDataService } from './item-data.service'; -import { UpdateDataService } from './update-data.service'; +import { BaseItemDataService } from './item-data.service'; import { Item } from '../shared/item.model'; -import { RestRequestMethod } from './rest-request-method'; import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { BrowseService } from '../browse/browse.service'; import { CollectionDataService } from './collection-data.service'; -import { map, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { BundleDataService } from './bundle-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { NoContent } from '../shared/NoContent.model'; -import { hasValue } from '../../shared/empty.util'; -import { Operation } from 'fast-json-patch'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { CreateDataImpl } from './base/create-data'; -/* tslint:disable:max-classes-per-file */ /** - * A custom implementation of the ItemDataService, but for collection item templates - * Makes sure to change the endpoint before sending out CRUD requests for the item template + * Data service for interacting with Item templates via their Collection */ -class DataServiceImpl extends ItemDataService { - protected collectionLinkPath = 'itemtemplate'; - protected linkPath = 'itemtemplates'; - - /** - * Endpoint dynamically changing depending on what request we're sending - */ - private endpoint$: Observable; - - /** - * Is the current endpoint based on a collection? - */ - private collectionEndpoint = false; +class CollectionItemTemplateDataService extends IdentifiableDataService { + private createData: CreateDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - protected bundleService: BundleDataService, - protected collectionService: CollectionDataService) { - super(requestService, rdbService, store, bs, objectCache, halService, notificationsService, http, comparator, bundleService); + protected collectionService: CollectionDataService, + ) { + super('itemtemplates', requestService, rdbService, objectCache, halService, undefined); + + // We only intend to use createOnEndpoint, so this inner data service feature doesn't need an endpoint at all + this.createData = new CreateDataImpl(undefined, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); } /** - * Get the endpoint based on a collection - * @param collectionID The ID of the collection to base the endpoint on + * Create an observable for the HREF of a specific object based on its identifier + * + * Overridden to ensure that {@link findById} works with Collection IDs and points to the template. + * @param collectionID the ID of a Collection */ - public getCollectionEndpoint(collectionID: string): Observable { + public getIDHrefObs(collectionID: string): Observable { return this.collectionService.getIDHrefObs(collectionID).pipe( - switchMap((href: string) => this.halService.getEndpoint(this.collectionLinkPath, href)) + switchMap((href: string) => this.halService.getEndpoint('itemtemplate', href)), ); } /** - * Set the endpoint to be based on a collection - * @param collectionID The ID of the collection to base the endpoint on - */ - private setCollectionEndpoint(collectionID: string) { - this.collectionEndpoint = true; - this.endpoint$ = this.getCollectionEndpoint(collectionID); - } - - /** - * Set the endpoint to the regular linkPath - */ - private setRegularEndpoint() { - this.collectionEndpoint = false; - this.endpoint$ = this.halService.getEndpoint(this.linkPath); - } - - /** - * Get the base endpoint for all requests - * Uses the current collectionID to assemble a request endpoint for the collection's item template - */ - protected getEndpoint(): Observable { - return this.endpoint$; - } - - /** - * If the current endpoint is based on a collection, simply return the collection's template endpoint, otherwise - * create a regular template endpoint - * @param resourceID - */ - getIDHrefObs(resourceID: string): Observable { - if (this.collectionEndpoint) { - return this.getEndpoint(); - } else { - return super.getIDHrefObs(resourceID); - } - } - - /** - * Set the collection ID and send a find by ID request - * @param collectionID - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByCollectionID(collectionID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - this.setCollectionEndpoint(collectionID); - return super.findById(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Set the collection ID and send a create request + * Create a new item template for a Collection by ID * @param item * @param collectionID */ - createTemplate(item: Item, collectionID: string): Observable> { - this.setCollectionEndpoint(collectionID); - return super.create(item); - } - - /** - * Set the collection ID and send a delete request - * @param item - * @param collectionID - */ - deleteByCollectionID(item: Item, collectionID: string): Observable { - this.setRegularEndpoint(); - return super.delete(item.uuid).pipe( - getFirstCompletedRemoteData(), - map((response: RemoteData) => hasValue(response) && response.hasSucceeded) - ); + public createTemplate(item: Item, collectionID: string): Observable> { + return this.createData.createOnEndpoint(item, this.getIDHrefObs(collectionID)); } } @@ -149,43 +64,23 @@ class DataServiceImpl extends ItemDataService { * A service responsible for fetching/sending data from/to the REST API on a collection's itemtemplates endpoint */ @Injectable() -export class ItemTemplateDataService implements UpdateDataService { - /** - * The data service responsible for all CRUD actions on the item - */ - private dataService: DataServiceImpl; +export class ItemTemplateDataService extends BaseItemDataService { + private byCollection: CollectionItemTemplateDataService; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, protected comparator: DSOChangeAnalyzer, + protected browseService: BrowseService, protected bundleService: BundleDataService, - protected collectionService: CollectionDataService) { - this.dataService = new DataServiceImpl(requestService, rdbService, store, bs, objectCache, halService, notificationsService, http, comparator, bundleService, collectionService); - } + protected collectionService: CollectionDataService, + ) { + super('itemtemplates', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); - /** - * Commit current object changes to the server - */ - commitUpdates(method?: RestRequestMethod) { - this.dataService.commitUpdates(method); - } - - /** - * Add a new patch to the object cache - */ - update(object: Item): Observable> { - return this.dataService.update(object); - } - - patch(dso: Item, operations: Operation[]): Observable> { - return this.dataService.patch(dso, operations); + this.byCollection = new CollectionItemTemplateDataService(requestService, rdbService, objectCache, halService, notificationsService, collectionService); } /** @@ -199,7 +94,7 @@ export class ItemTemplateDataService implements UpdateDataService { * {@link HALLink}s should be automatically resolved */ findByCollectionID(collectionID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByCollectionID(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.byCollection.findById(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -207,17 +102,8 @@ export class ItemTemplateDataService implements UpdateDataService { * @param item * @param collectionID */ - create(item: Item, collectionID: string): Observable> { - return this.dataService.createTemplate(item, collectionID); - } - - /** - * Delete a template item by collection ID - * @param item - * @param collectionID - */ - deleteByCollectionID(item: Item, collectionID: string): Observable { - return this.dataService.deleteByCollectionID(item, collectionID); + createByCollectionID(item: Item, collectionID: string): Observable> { + return this.byCollection.createTemplate(item, collectionID); } /** @@ -225,7 +111,6 @@ export class ItemTemplateDataService implements UpdateDataService { * @param collectionID The ID of the collection to base the endpoint on */ getCollectionEndpoint(collectionID: string): Observable { - return this.dataService.getCollectionEndpoint(collectionID); + return this.byCollection.getIDHrefObs(collectionID); } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index c9e523f796..58598b9870 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -1,5 +1,5 @@ import { LookupRelationService } from './lookup-relation.service'; -import { ExternalSourceService } from './external-source.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'; @@ -16,7 +16,7 @@ import { of as observableOf } from 'rxjs'; describe('LookupRelationService', () => { let service: LookupRelationService; - let externalSourceService: ExternalSourceService; + let externalSourceService: ExternalSourceDataService; let searchService: SearchService; let requestService: RequestService; diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 7808a24e92..7a6bc2358b 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -1,4 +1,4 @@ -import { ExternalSourceService } from './external-source.service'; +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 { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; @@ -34,7 +34,7 @@ export class LookupRelationService { pageSize: 1 }); - constructor(protected externalSourceService: ExternalSourceService, + constructor(protected externalSourceService: ExternalSourceDataService, protected searchService: SearchService, protected requestService: RequestService) { } 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 bb621f74b3..1ce078f5d5 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -4,12 +4,17 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { of as observableOf } from 'rxjs'; import { RestResponse } from '../cache/response.models'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { FindListOptions } from './request.models'; import { MetadataFieldDataService } from './metadata-field-data.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RequestParam } from '../cache/models/request-param.model'; +import { FindListOptions } from './find-list-options.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +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'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -33,22 +38,33 @@ describe('MetadataFieldDataService', () => { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', send: {}, getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), - setStaleByHrefSubstring: {} + setStaleByHrefSubstring: {}, }); halService = Object.assign(new HALEndpointServiceStub(endpoint)); notificationsService = jasmine.createSpyObj('notificationsService', { - error: {} + error: {}, }); rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: createSuccessfulRemoteDataObject$(undefined) + buildSingle: createSuccessfulRemoteDataObject$(undefined), + buildList: createSuccessfulRemoteDataObject$(createPaginatedList([])), }); - metadataFieldService = new MetadataFieldDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + metadataFieldService = new MetadataFieldDataService( + requestService, rdbService, undefined, halService, notificationsService, + ); } beforeEach(() => { init(); }); + describe('composition', () => { + const initService = () => new MetadataFieldDataService(null, null, null, null, null); + testCreateDataImplementation(initService); + testSearchDataImplementation(initService); + testPutDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('findBySchema', () => { beforeEach(() => { spyOn(metadataFieldService, 'searchBy'); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 3b11859361..d05e3533d3 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,47 +1,55 @@ import { Injectable } from '@angular/core'; import { hasValue } from '../../shared/empty.util'; -import { dataService } from '../cache/builders/build-decorators'; -import { DataService } from './data.service'; 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 { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { HttpClient } from '@angular/common/http'; -import { NotificationsService } from '../../shared/notifications/notifications.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 { FindListOptions } from './request.models'; 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) -export class MetadataFieldDataService extends DataService { - protected linkPath = 'metadatafields'; +export class MetadataFieldDataService extends IdentifiableDataService implements CreateData, PutData, DeleteData, SearchData { + private createData: CreateData; + private searchData: SearchData; + private putData: PutData; + private deleteData: DeleteData; + protected searchBySchemaLinkPath = 'bySchema'; protected searchByFieldNameLinkPath = 'byFieldName'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: DefaultChangeAnalyzer, - protected http: HttpClient, - protected notificationsService: NotificationsService) { - super(); + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('metadatafields', requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -57,7 +65,7 @@ export class MetadataFieldDataService extends DataService { */ findBySchema(schema: MetadataSchema, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { const optionsWithSchema = Object.assign(new FindListOptions(), options, { - searchParams: [new RequestParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)], }); return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -85,8 +93,8 @@ export class MetadataFieldDataService extends DataService { new RequestParam('element', hasValue(element) ? element : ''), new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''), new RequestParam('query', hasValue(query) ? query : ''), - new RequestParam('exactName', hasValue(exactName) ? exactName : '') - ] + new RequestParam('exactName', hasValue(exactName) ? exactName : ''), + ], }); return this.searchBy(this.searchByFieldNameLinkPath, optionParams, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -112,4 +120,67 @@ export class MetadataFieldDataService extends DataService { } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: MetadataField): Observable> { + return this.putData.put(object); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: MetadataField, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } 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 2e61955502..1bcf4e1104 100644 --- a/src/app/core/data/metadata-schema-data.service.spec.ts +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -9,6 +9,8 @@ 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 { testDeleteDataImplementation } from './base/delete-data.spec'; describe('MetadataSchemaDataService', () => { let metadataSchemaService: MetadataSchemaDataService; @@ -24,20 +26,33 @@ describe('MetadataSchemaDataService', () => { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', send: {}, getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); halService = Object.assign(new HALEndpointServiceStub(endpoint)); notificationsService = jasmine.createSpyObj('notificationsService', { - error: {} + error: {}, }); rdbService = getMockRemoteDataBuildService(); - metadataSchemaService = new MetadataSchemaDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + metadataSchemaService = new MetadataSchemaDataService( + requestService, + rdbService, + null, + halService, + notificationsService, + ); } beforeEach(() => { init(); }); + describe('composition', () => { + const initService = () => new MetadataSchemaDataService(null, null, null, null, null); + + testFindAllDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('createOrUpdateMetadataSchema', () => { let schema: MetadataSchema; diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index ff1796313e..6bd633b8c6 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,40 +1,50 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.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 { IdentifiableDataService } from './base/identifiable-data.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { dataService } from './base/data-service.decorator'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ @Injectable() @dataService(METADATA_SCHEMA) -export class MetadataSchemaDataService extends DataService { - protected linkPath = 'metadataschemas'; +export class MetadataSchemaDataService extends IdentifiableDataService implements FindAllData, DeleteData { + private createData: CreateData; + private findAllData: FindAllData; + private putData: PutData; + private deleteData: DeleteData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: DefaultChangeAnalyzer, - protected http: HttpClient, - protected notificationsService: NotificationsService) { - super(); + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('metadataschemas', requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -49,9 +59,9 @@ export class MetadataSchemaDataService extends DataService { const isUpdate = hasValue(schema.id); if (isUpdate) { - return this.put(schema); + return this.putData.put(schema); } else { - return this.create(schema); + return this.createData.create(schema); } } @@ -61,8 +71,50 @@ export class MetadataSchemaDataService extends DataService { */ clearRequests(): Observable { return this.getBrowseEndpoint().pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) + tap((href: string) => this.requestService.removeByHrefSubstring(href)), ); } + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index e111aca9dd..e46e319149 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@angular/core'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { RestRequest } from './request.models'; 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 { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { RestRequest } from './rest-request.model'; @Injectable() export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingService { diff --git a/src/app/core/data/object-updates/field-change-type.model.ts b/src/app/core/data/object-updates/field-change-type.model.ts new file mode 100644 index 0000000000..7d8e308945 --- /dev/null +++ b/src/app/core/data/object-updates/field-change-type.model.ts @@ -0,0 +1,8 @@ +/** + * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store + */ +export enum FieldChangeType { + UPDATE = 0, + ADD = 1, + REMOVE = 2 +} diff --git a/src/app/core/data/object-updates/field-update.model.ts b/src/app/core/data/object-updates/field-update.model.ts new file mode 100644 index 0000000000..47b6782471 --- /dev/null +++ b/src/app/core/data/object-updates/field-update.model.ts @@ -0,0 +1,10 @@ +import { Identifiable } from './identifiable.model'; +import { FieldChangeType } from './field-change-type.model'; + +/** + * The state of a single field update + */ +export interface FieldUpdate { + field: Identifiable; + changeType: FieldChangeType; +} diff --git a/src/app/core/data/object-updates/field-updates.model.ts b/src/app/core/data/object-updates/field-updates.model.ts new file mode 100644 index 0000000000..eff804bd02 --- /dev/null +++ b/src/app/core/data/object-updates/field-updates.model.ts @@ -0,0 +1,8 @@ +import { FieldUpdate } from './field-update.model'; + +/** + * The states of all field updates available for a single page, mapped by uuid + */ +export interface FieldUpdates { + [uuid: string]: FieldUpdate; +} diff --git a/src/app/core/data/object-updates/identifiable.model.ts b/src/app/core/data/object-updates/identifiable.model.ts new file mode 100644 index 0000000000..7d32859338 --- /dev/null +++ b/src/app/core/data/object-updates/identifiable.model.ts @@ -0,0 +1,6 @@ +/** + * Represents every object that has a UUID + */ +export interface Identifiable { + uuid: string; +} 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 13bbabb286..615dedbaf9 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,9 +1,11 @@ -import {type} from '../../../shared/ngrx/type'; -import {Action} from '@ngrx/store'; -import {Identifiable} from './object-updates.reducer'; -import {INotification} from '../../../shared/notifications/models/notification.model'; +/* eslint-disable max-classes-per-file */ +import { type } from '../../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +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'; /** * The list of ObjectUpdatesAction type definitions @@ -21,16 +23,6 @@ export const ObjectUpdatesActionTypes = { REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') }; -/* tslint:disable:max-classes-per-file */ - -/** - * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store - */ -export enum FieldChangeType { - UPDATE = 0, - ADD = 1, - REMOVE = 2 -} /** * An ngrx action to initialize a new page's fields in the ObjectUpdates state @@ -283,7 +275,6 @@ export class RemoveFieldUpdateAction implements Action { } } -/* tslint:enable:max-classes-per-file */ /** * A type to encompass all ObjectUpdatesActions 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 c9c3237ef5..1dfdc95f23 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, @@ -52,7 +52,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 */ - @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + mapLastActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { @@ -64,12 +64,12 @@ 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 */ - @Effect({ dispatch: false }) mapLastNotificationActions$ = this.actions$ + mapLastNotificationActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(NotificationsActionTypes)), map((action: RemoveNotificationAction) => { @@ -80,7 +80,7 @@ export class ObjectUpdatesEffects { this.notificationActionMap$[id].next(action); } ) - ); + ), { dispatch: false }); /** * Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction @@ -88,7 +88,7 @@ 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 */ - @Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$ + removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ .pipe( ofType(ObjectUpdatesActionTypes.DISCARD), switchMap((action: DiscardObjectUpdatesAction) => { @@ -134,7 +134,7 @@ export class ObjectUpdatesEffects { ); } ) - ); + )); constructor(private actions$: Actions, private notificationsService: NotificationsService) { 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 4d7fce24a7..08944a073f 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,8 +1,8 @@ +// eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, @@ -14,6 +14,7 @@ import { } 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'; class NullAction extends RemoveFieldUpdateAction { type = null; 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 6dfb4ab584..14bacc52db 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,16 +1,15 @@ import { AddFieldUpdateAction, DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction, - SelectVirtualMetadataAction, } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { Relationship } from '../../shared/item-relationships/relationship.model'; @@ -18,6 +17,9 @@ import { PatchOperationService } from './patch-operation-service/patch-operation 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 @@ -40,28 +42,6 @@ export interface FieldStates { [uuid: string]: FieldState; } -/** - * Represents every object that has a UUID - */ -export interface Identifiable { - uuid: string; -} - -/** - * The state of a single field update - */ -export interface FieldUpdate { - field: Identifiable; - changeType: FieldChangeType; -} - -/** - * The states of all field updates available for a single page, mapped by uuid - */ -export interface FieldUpdates { - [uuid: string]: FieldUpdate; -} - /** * The states of all virtual metadata selections available for a single page, mapped by the relationship uuid */ 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 6c0b0f99c4..9cf856f03a 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,9 +1,7 @@ import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectUpdatesService } from './object-updates.service'; import { DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, @@ -16,6 +14,8 @@ import { NotificationType } from '../../../shared/notifications/models/notificat 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'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; 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 88c7c0e453..2fb6d47d31 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,11 +1,8 @@ import { Injectable, Injector } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { coreSelector } from '../../core.selectors'; import { FieldState, - FieldUpdates, - Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, ObjectUpdatesState, @@ -15,7 +12,6 @@ import { Observable } from 'rxjs'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, @@ -35,6 +31,10 @@ import { INotification } from '../../../shared/notifications/models/notification import { Operation } from 'fast-json-patch'; 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']); 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 9708266cd7..db46426b79 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,8 @@ import { MetadataPatchOperationService } from './metadata-patch-operation.service'; -import { FieldUpdates } from '../object-updates.reducer'; import { Operation } from 'fast-json-patch'; -import { FieldChangeType } from '../object-updates.actions'; import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldUpdates } from '../field-updates.model'; +import { FieldChangeType } from '../field-change-type.model'; describe('MetadataPatchOperationService', () => { let service: MetadataPatchOperationService; 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 59c981872a..33e9129a9d 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,14 +1,14 @@ import { PatchOperationService } from './patch-operation.service'; import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { FieldUpdates } from '../object-updates.reducer'; import { Operation } from 'fast-json-patch'; -import { FieldChangeType } from '../object-updates.actions'; import { Injectable } from '@angular/core'; import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; import { hasValue } from '../../../../shared/empty.util'; import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-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'; /** * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values 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 7c67f9a2e5..171c1d2a54 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,5 +1,5 @@ -import { FieldUpdates } from '../object-updates.reducer'; import { Operation } from 'fast-json-patch'; +import { FieldUpdates } from '../field-updates.model'; /** * Interface for a service dealing with the transformations of patch operations from the object-updates store diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index e85a91f791..415bfe234e 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -3,11 +3,11 @@ import { hasValue, isEmpty, hasNoValue, isUndefined } from '../../shared/empty.u import { HALResource } from '../shared/hal-resource.model'; import { HALLink } from '../shared/hal-link.model'; import { typedObject } from '../cache/builders/build-decorators'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { PAGINATED_LIST } from './paginated-list.resource-type'; import { ResourceType } from '../shared/resource-type'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { autoserialize, deserialize } from 'cerialize'; +import { CacheableObject } from '../cache/cacheable-object.model'; /** * Factory function for a paginated list diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index bebbd63fd7..fbebe75b2b 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,6 +1,6 @@ import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestRequest } from './request.models'; import { ParsedResponse } from '../cache/response.models'; +import { RestRequest } from './rest-request.model'; export interface ResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse; diff --git a/src/app/core/data/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts new file mode 100644 index 0000000000..88e5bd5791 --- /dev/null +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -0,0 +1,19 @@ +/** + * 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 { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { ProcessDataService } from './process-data.service'; +import { testDeleteDataImplementation } from '../base/delete-data.spec'; + +describe('ProcessDataService', () => { + describe('composition', () => { + const initService = () => new ProcessDataService(null, null, null, null, null, null); + testFindAllDataImplementation(initService); + testDeleteDataImplementation(initService); + }); +}); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index cadcdb3bfe..3bf34eb650 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,16 +1,9 @@ import { Injectable } from '@angular/core'; -import { DataService } from '../data.service'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { Process } from '../../../process-page/processes/process.model'; -import { dataService } from '../../cache/builders/build-decorators'; import { PROCESS } from '../../../process-page/processes/process.resource-type'; import { Observable } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -18,27 +11,37 @@ 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 { NoContent } from '../../shared/NoContent.model'; @Injectable() @dataService(PROCESS) -export class ProcessDataService extends DataService { - protected linkPath = 'processes'; +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { + private findAllData: FindAllData; + private deleteData: DeleteData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, protected bitstreamDataService: BitstreamDataService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected notificationsService: NotificationsService, + ) { + 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); } /** - * Get the endpoint for a process his files + * Get the endpoint for the files of the process * @param processId The ID of the process */ getFilesEndpoint(processId: string): Observable { @@ -53,6 +56,49 @@ export class ProcessDataService extends DataService { */ getFiles(processId: string): Observable>> { const href$ = this.getFilesEndpoint(processId); - return this.bitstreamDataService.findAllByHref(href$); + return this.bitstreamDataService.findListByHref(href$); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); } } diff --git a/src/app/core/data/processes/script-data.service.spec.ts b/src/app/core/data/processes/script-data.service.spec.ts new file mode 100644 index 0000000000..0aee3cfb46 --- /dev/null +++ b/src/app/core/data/processes/script-data.service.spec.ts @@ -0,0 +1,17 @@ +/** + * 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 { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { ScriptDataService } from './script-data.service'; + +describe('ScriptDataService', () => { + describe('composition', () => { + const initService = () => new ScriptDataService(null, null, null, null); + testFindAllDataImplementation(initService); + }); +}); diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 69b4270173..d9c92cb1d2 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,43 +1,46 @@ import { Injectable } from '@angular/core'; -import { DataService } from '../data.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../default-change-analyzer.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 { URLCombiner } from '../../url-combiner/url-combiner'; import { RemoteData } from '../remote-data'; -import { MultipartPostRequest, RestRequest } from '../request.models'; +import { MultipartPostRequest } from '../request.models'; import { RequestService } from '../request.service'; import { Observable } from 'rxjs'; -import { dataService } from '../../cache/builders/build-decorators'; 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) -export class ScriptDataService extends DataService + diff --git a/src/main.browser.ts b/src/main.browser.ts index de4276ea93..d5efe828c3 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -1,4 +1,4 @@ -import 'zone.js/dist/zone'; +import 'zone.js'; import 'reflect-metadata'; import 'core-js/es/reflect'; @@ -15,9 +15,7 @@ import { AppConfig } from './config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './config/config.util'; const bootstrap = () => platformBrowserDynamic() - .bootstrapModule(BrowserAppModule, { - preserveWhitespaces: true - }); + .bootstrapModule(BrowserAppModule, {}); const main = () => { // Load fonts async @@ -30,14 +28,14 @@ const main = () => { if (environment.production) { enableProdMode(); + } + if (hasValue(environment.universal) && environment.universal.preboot) { return bootstrap(); } else { - return fetch('assets/config.json') .then((response) => response.json()) .then((appConfig: AppConfig) => { - // extend environment with app config for browser when not prerendered extendEnvironmentWithAppConfig(environment, appConfig); @@ -47,7 +45,7 @@ const main = () => { }; // support async tag or hmr -if (hasValue(environment.universal) && environment.universal.preboot === false) { +if (document.readyState === 'complete' && hasValue(environment.universal) && !environment.universal.preboot) { main(); } else { document.addEventListener('DOMContentLoaded', main); diff --git a/src/main.server.ts b/src/main.server.ts index 83ca0192a0..91425136f8 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -1,5 +1,5 @@ import 'core-js/es/reflect'; -import 'zone.js/dist/zone'; +import 'zone.js'; import 'reflect-metadata'; import { enableProdMode } from '@angular/core'; diff --git a/src/mirador-viewer/mirador.html b/src/mirador-viewer/mirador.html index 6a9547133c..3cd1e16501 100644 --- a/src/mirador-viewer/mirador.html +++ b/src/mirador-viewer/mirador.html @@ -6,5 +6,6 @@
+ diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 88a59eb157..6baf339003 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -1,24 +1,21 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; -import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; +import { BrowserModule, BrowserTransferStateModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterModule, NoPreloading } from '@angular/router'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateJson5HttpLoader } from '../../ngx-translate-loaders/translate-json5-http.loader'; +import { TranslateBrowserLoader } from '../../ngx-translate-loaders/translate-browser.loader'; import { IdlePreloadModule } from 'angular-idle-preload'; import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; -import { DSpaceBrowserTransferStateModule } from '../transfer-state/dspace-browser-transfer-state.module'; -import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { ClientCookieService } from '../../app/core/services/client-cookie.service'; import { CookieService } from '../../app/core/services/cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; -import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; +import { Angulartics2GoogleTagManager, Angulartics2RouterlessModule } from 'angulartics2'; import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service'; @@ -33,17 +30,12 @@ import { LocaleService } from '../../app/core/locale/locale.service'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; -import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; -import { DefaultAppConfig } from '../../config/default-app-config'; -import { extendEnvironmentWithAppConfig } from '../../config/config.util'; -import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; - -import { environment } from '../../environments/environment'; +import { BrowserInitService } from './browser-init.service'; export const REQ_KEY = makeStateKey('req'); -export function createTranslateLoader(http: HttpClient) { - return new TranslateJson5HttpLoader(http, 'assets/i18n/', '.json5'); +export function createTranslateLoader(transferState: TransferState, http: HttpClient) { + return new TranslateBrowserLoader(transferState, http, 'assets/i18n/', '.json'); } export function getRequest(transferState: TransferState): any { @@ -59,46 +51,21 @@ export function getRequest(transferState: TransferState): any { HttpClientModule, // forRoot ensures the providers are only created once IdlePreloadModule.forRoot(), - RouterModule.forRoot([], { - // enableTracing: true, - useHash: false, - scrollPositionRestoration: 'enabled', - anchorScrolling: 'enabled', - preloadingStrategy: NoPreloading - }), StatisticsModule.forRoot(), Angulartics2RouterlessModule.forRoot(), BrowserAnimationsModule, - DSpaceBrowserTransferStateModule, + BrowserTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: (createTranslateLoader), - deps: [HttpClient] + deps: [TransferState, HttpClient] } }), AppModule ], providers: [ - { - provide: APP_INITIALIZER, - useFactory: ( - transferState: TransferState, - dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService - ) => { - if (transferState.hasKey(APP_CONFIG_STATE)) { - const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); - // extend environment with app config for browser - extendEnvironmentWithAppConfig(environment, appConfig); - } - dspaceTransferState.transfer(); - correlationIdService.initCorrelationId(); - return () => true; - }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], - multi: true - }, + ...BrowserInitService.providers(), { provide: REQUEST, useFactory: getRequest, @@ -132,6 +99,10 @@ export function getRequest(transferState: TransferState): any { provide: GoogleAnalyticsService, useClass: GoogleAnalyticsService, }, + { + provide: Angulartics2GoogleTagManager, + useClass: Angulartics2GoogleTagManager + }, { provide: AuthRequestService, useClass: BrowserAuthRequestService, diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts new file mode 100644 index 0000000000..1135de5e93 --- /dev/null +++ b/src/modules/app/browser-init.service.ts @@ -0,0 +1,134 @@ +/** + * 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 { InitService } from '../../app/init.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app/app.reducer'; +import { TransferState } from '@angular/platform-browser'; +import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; +import { DefaultAppConfig } from '../../config/default-app-config'; +import { extendEnvironmentWithAppConfig } from '../../config/config.util'; +import { environment } from '../../environments/environment'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; +import { AuthService } from '../../app/core/auth/auth.service'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { StoreAction, StoreActionTypes } from '../../app/store.actions'; +import { coreSelector } from '../../app/core/core.selectors'; +import { find, map } from 'rxjs/operators'; +import { isNotEmpty } from '../../app/shared/empty.util'; +import { logStartupMessage } from '../../../startup-message'; + +/** + * Performs client-side initialization. + */ +@Injectable() +export class BrowserInitService extends InitService { + constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + protected transferState: TransferState, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected googleAnalyticsService: GoogleAnalyticsService, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected klaroService: KlaroService, + protected authService: AuthService, + protected themeService: ThemeService, + ) { + super( + store, + correlationIdService, + appConfig, + translate, + localeService, + angulartics2DSpace, + metadata, + breadcrumbsService, + themeService, + ); + } + + protected static resolveAppConfig( + transferState: TransferState, + ) { + if (transferState.hasKey(APP_CONFIG_STATE)) { + const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); + // extend environment with app config for browser + extendEnvironmentWithAppConfig(environment, appConfig); + } + } + + protected init(): () => Promise { + return async () => { + await this.loadAppState(); + this.checkAuthenticationToken(); + this.initCorrelationId(); + + this.checkEnvironment(); + logStartupMessage(environment); + + this.initI18n(); + this.initAngulartics(); + this.initGoogleAnalytics(); + this.initRouteListeners(); + this.themeService.listenForThemeChanges(true); + this.trackAuthTokenExpiration(); + + this.initKlaro(); + + await this.authenticationReady$().toPromise(); + + return true; + }; + } + + // Browser-only initialization steps + + /** + * Retrieve server-side application state from the {@link NGRX_STATE} key and rehydrate the store. + * Resolves once the store is no longer empty. + * @private + */ + private async loadAppState(): Promise { + const state = this.transferState.get(InitService.NGRX_STATE, null); + this.transferState.remove(InitService.NGRX_STATE); + this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); + return this.store.select(coreSelector).pipe( + find((core: any) => isNotEmpty(core)), + map(() => true) + ).toPromise(); + } + + private trackAuthTokenExpiration(): void { + this.authService.trackTokenExpiration(); + } + + /** + * Initialize Klaro (once authentication is resolved) + * @protected + */ + protected initKlaro() { + this.authenticationReady$().subscribe(() => { + this.klaroService.initialize(); + }); + } + + protected initGoogleAnalytics() { + this.googleAnalyticsService.addTrackingIdToPage(); + } +} diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 01a5548948..17e394ede8 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -1,21 +1,17 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; import { BrowserModule, TransferState } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ServerModule } from '@angular/platform-server'; -import { RouterModule } from '@angular/router'; +import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { Angulartics2 } from 'angulartics2'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2, Angulartics2GoogleAnalytics, Angulartics2GoogleTagManager } from 'angulartics2'; import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; -import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module'; -import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; -import { TranslateJson5UniversalLoader } from '../../ngx-translate-loaders/translate-json5-universal.loader'; +import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader'; import { CookieService } from '../../app/core/services/cookie.service'; import { ServerCookieService } from '../../app/core/services/server-cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; @@ -32,13 +28,10 @@ import { ServerHardRedirectService } from '../../app/core/services/server-hard-r import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; -import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; -import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; +import { ServerInitService } from './server-init.service'; -import { environment } from '../../environments/environment'; - -export function createTranslateLoader() { - return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); +export function createTranslateLoader(transferState: TransferState) { + return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json'); } @NgModule({ @@ -47,38 +40,20 @@ export function createTranslateLoader() { BrowserModule.withServerTransition({ appId: 'dspace-angular' }), - RouterModule.forRoot([], { - useHash: false - }), NoopAnimationsModule, - DSpaceServerTransferStateModule, + ServerTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: (createTranslateLoader), - deps: [] + deps: [TransferState] } }), + AppModule, ServerModule, - AppModule ], providers: [ - // Initialize app config and extend environment - { - provide: APP_INITIALIZER, - useFactory: ( - transferState: TransferState, - dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService, - ) => { - transferState.set(APP_CONFIG_STATE, environment as AppConfig); - dspaceTransferState.transfer(); - correlationIdService.initCorrelationId(); - return () => true; - }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], - multi: true - }, + ...ServerInitService.providers(), { provide: Angulartics2, useClass: Angulartics2Mock @@ -87,6 +62,10 @@ export function createTranslateLoader() { provide: Angulartics2GoogleAnalytics, useClass: AngularticsProviderMock }, + { + provide: Angulartics2GoogleTagManager, + useClass: AngularticsProviderMock + }, { provide: Angulartics2DSpace, useClass: AngularticsProviderMock diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts new file mode 100644 index 0000000000..903bd91b7c --- /dev/null +++ b/src/modules/app/server-init.service.ts @@ -0,0 +1,93 @@ +/** + * 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 { InitService } from '../../app/init.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app/app.reducer'; +import { TransferState } from '@angular/platform-browser'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; +import { environment } from '../../environments/environment'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { take } from 'rxjs/operators'; + +/** + * Performs server-side initialization. + */ +@Injectable() +export class ServerInitService extends InitService { + constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + protected transferState: TransferState, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected themeService: ThemeService, + ) { + super( + store, + correlationIdService, + appConfig, + translate, + localeService, + angulartics2DSpace, + metadata, + breadcrumbsService, + themeService, + ); + } + + protected init(): () => Promise { + return async () => { + this.checkAuthenticationToken(); + this.saveAppConfigForCSR(); + this.saveAppState(); + this.initCorrelationId(); + + this.checkEnvironment(); + this.initI18n(); + this.initAngulartics(); + this.initRouteListeners(); + this.themeService.listenForThemeChanges(false); + + await this.authenticationReady$().toPromise(); + + return true; + }; + } + + // Server-only initialization steps + + /** + * Set the {@link NGRX_STATE} key when state is serialized to be transfered + * @private + */ + private saveAppState() { + this.transferState.onSerialize(InitService.NGRX_STATE, () => { + let state; + this.store.pipe(take(1)).subscribe((saveState: any) => { + state = saveState; + }); + + return state; + }); + } + + private saveAppConfigForCSR(): void { + this.transferState.set(APP_CONFIG_STATE, environment as AppConfig); + } +} diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.module.ts b/src/modules/transfer-state/dspace-browser-transfer-state.module.ts deleted file mode 100644 index e251d0b3b2..0000000000 --- a/src/modules/transfer-state/dspace-browser-transfer-state.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { BrowserTransferStateModule } from '@angular/platform-browser'; -import { DSpaceBrowserTransferState } from './dspace-browser-transfer-state.service'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@NgModule({ - imports: [ - BrowserTransferStateModule - ], - providers: [ - { provide: DSpaceTransferState, useClass: DSpaceBrowserTransferState } - ] -}) -export class DSpaceBrowserTransferStateModule { - -} diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts b/src/modules/transfer-state/dspace-browser-transfer-state.service.ts deleted file mode 100644 index ae3306c3fb..0000000000 --- a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@angular/core'; -import { StoreAction, StoreActionTypes } from '../../app/store.actions'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@Injectable() -export class DSpaceBrowserTransferState extends DSpaceTransferState { - transfer() { - const state = this.transferState.get(DSpaceTransferState.NGRX_STATE, null); - this.transferState.remove(DSpaceTransferState.NGRX_STATE); - this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); - } -} diff --git a/src/modules/transfer-state/dspace-server-transfer-state.module.ts b/src/modules/transfer-state/dspace-server-transfer-state.module.ts deleted file mode 100644 index f8f2631cd0..0000000000 --- a/src/modules/transfer-state/dspace-server-transfer-state.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { ServerTransferStateModule } from '@angular/platform-server'; -import { DSpaceServerTransferState } from './dspace-server-transfer-state.service'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@NgModule({ - imports: [ - ServerTransferStateModule - ], - providers: [ - { provide: DSpaceTransferState, useClass: DSpaceServerTransferState } - ] -}) -export class DSpaceServerTransferStateModule { - -} diff --git a/src/modules/transfer-state/dspace-server-transfer-state.service.ts b/src/modules/transfer-state/dspace-server-transfer-state.service.ts deleted file mode 100644 index ac8c817d84..0000000000 --- a/src/modules/transfer-state/dspace-server-transfer-state.service.ts +++ /dev/null @@ -1,18 +0,0 @@ - -import {take} from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@Injectable() -export class DSpaceServerTransferState extends DSpaceTransferState { - transfer() { - this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => { - let state; - this.store.pipe(take(1)).subscribe((saveState: any) => { - state = saveState; - }); - - return state; - }); - } -} diff --git a/src/modules/transfer-state/dspace-transfer-state.service.ts b/src/modules/transfer-state/dspace-transfer-state.service.ts deleted file mode 100644 index 05b1109f17..0000000000 --- a/src/modules/transfer-state/dspace-transfer-state.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@angular/core'; -import { makeStateKey, TransferState } from '@angular/platform-browser'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../app/app.reducer'; - -@Injectable() -export abstract class DSpaceTransferState { - - protected static NGRX_STATE = makeStateKey('NGRX_STATE'); - - constructor( - protected transferState: TransferState, - protected store: Store - ) { - } - - abstract transfer(): void; -} diff --git a/src/ngx-translate-loaders/ngx-translate-state.ts b/src/ngx-translate-loaders/ngx-translate-state.ts new file mode 100644 index 0000000000..4e6c2f496b --- /dev/null +++ b/src/ngx-translate-loaders/ngx-translate-state.ts @@ -0,0 +1,15 @@ +import { makeStateKey } from '@angular/platform-browser'; + +/** + * Represents ngx-translate messages in different languages in the TransferState + */ +export class NgxTranslateState { + [lang: string]: { + [key: string]: string + } +} + +/** + * The key to store the NgxTranslateState as part of the TransferState + */ +export const NGX_TRANSLATE_STATE = makeStateKey('NGX_TRANSLATE_STATE'); diff --git a/src/ngx-translate-loaders/translate-browser.loader.ts b/src/ngx-translate-loaders/translate-browser.loader.ts new file mode 100644 index 0000000000..a6188c9f15 --- /dev/null +++ b/src/ngx-translate-loaders/translate-browser.loader.ts @@ -0,0 +1,43 @@ +import { TranslateLoader } from '@ngx-translate/core'; +import { HttpClient } from '@angular/common/http'; +import { TransferState } from '@angular/platform-browser'; +import { NGX_TRANSLATE_STATE, NgxTranslateState } from './ngx-translate-state'; +import { hasValue } from '../app/shared/empty.util'; +import { map } from 'rxjs/operators'; +import { of as observableOf, Observable } from 'rxjs'; + +/** + * A TranslateLoader for ngx-translate to retrieve i18n messages from the TransferState, or download + * them if they're not available there + */ +export class TranslateBrowserLoader implements TranslateLoader { + constructor( + protected transferState: TransferState, + protected http: HttpClient, + protected prefix?: string, + protected suffix?: string + ) { + } + + /** + * Return the i18n messages for a given language, first try to find them in the TransferState + * retrieve them using HttpClient if they're not available there + * + * @param lang the language code + */ + getTranslation(lang: string): Observable { + // Get the ngx-translate messages from the transfer state, to speed up the initial page load + // client side + const state = this.transferState.get(NGX_TRANSLATE_STATE, {}); + const messages = state[lang]; + if (hasValue(messages)) { + return observableOf(messages); + } else { + // If they're not available on the transfer state (e.g. when running in dev mode), retrieve + // them using HttpClient + return this.http.get('' + this.prefix + lang + this.suffix, { responseType: 'text' }).pipe( + map((json: any) => JSON.parse(json)) + ); + } + } +} diff --git a/src/ngx-translate-loaders/translate-json5-http.loader.ts b/src/ngx-translate-loaders/translate-json5-http.loader.ts deleted file mode 100644 index b6759408ce..0000000000 --- a/src/ngx-translate-loaders/translate-json5-http.loader.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { TranslateLoader } from '@ngx-translate/core'; -import { map } from 'rxjs/operators'; -import * as JSON5 from 'json5'; - -export class TranslateJson5HttpLoader implements TranslateLoader { - constructor(private http: HttpClient, public prefix?: string, public suffix?: string) { - } - - getTranslation(lang: string): any { - return this.http.get('' + this.prefix + lang + this.suffix, {responseType: 'text'}).pipe( - map((json: any) => JSON5.parse(json)) - ); - } -} diff --git a/src/ngx-translate-loaders/translate-json5-universal.loader.ts b/src/ngx-translate-loaders/translate-json5-universal.loader.ts deleted file mode 100644 index c557fb9a3e..0000000000 --- a/src/ngx-translate-loaders/translate-json5-universal.loader.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TranslateLoader } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import * as JSON5 from 'json5' -import * as fs from 'fs'; - -export class TranslateJson5UniversalLoader implements TranslateLoader { - - constructor(private prefix: string = 'dist/assets/i18n/', private suffix: string = '.json') { } - - public getTranslation(lang: string): Observable { - return Observable.create((observer: any) => { - observer.next(JSON5.parse(fs.readFileSync(`${this.prefix}${lang}${this.suffix}`, 'utf8'))); - observer.complete(); - }); - } - -} diff --git a/src/ngx-translate-loaders/translate-server.loader.ts b/src/ngx-translate-loaders/translate-server.loader.ts new file mode 100644 index 0000000000..c09c71f049 --- /dev/null +++ b/src/ngx-translate-loaders/translate-server.loader.ts @@ -0,0 +1,50 @@ +import { TranslateLoader } from '@ngx-translate/core'; +import { Observable, of as observableOf } from 'rxjs'; +import { readFileSync } from 'fs'; +import { TransferState } from '@angular/platform-browser'; +import { NGX_TRANSLATE_STATE, NgxTranslateState } from './ngx-translate-state'; + +/** + * A TranslateLoader for ngx-translate to parse json5 files server-side, and store them in the + * TransferState + */ +export class TranslateServerLoader implements TranslateLoader { + + constructor( + protected transferState: TransferState, + protected prefix: string = 'dist/assets/i18n/', + protected suffix: string = '.json' + ) { + } + + /** + * Return the i18n messages for a given language, and store them in the TransferState + * + * @param lang the language code + */ + public getTranslation(lang: string): Observable { + // Retrieve the file for the given language, and parse it + const messages = JSON.parse(readFileSync(`${this.prefix}${lang}${this.suffix}`, 'utf8')); + // Store the parsed messages in the transfer state so they'll be available immediately when the + // app loads on the client + this.storeInTransferState(lang, messages); + // Return the parsed messages to translate things server side + return observableOf(messages); + } + + /** + * Store the i18n messages for the given language code in the transfer state, so they can be + * retrieved client side + * + * @param lang the language code + * @param messages the i18n messages + * @protected + */ + protected storeInTransferState(lang: string, messages) { + const prevState = this.transferState.get(NGX_TRANSLATE_STATE, {}); + const nextState = Object.assign({}, prevState, { + [lang]: messages + }); + this.transferState.set(NGX_TRANSLATE_STATE, nextState); + } +} diff --git a/src/polyfills.ts b/src/polyfills.ts index 92b55f1ac5..e8ab71da80 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -18,16 +18,6 @@ * BROWSER POLYFILLS */ -/** IE10 and IE11 requires the following for NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** - * Web Animations `@angular/platform-browser/animations` - * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. - * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). - */ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - /** * By default, zone.js will patch all possible macroTask and DomEvents * user can disable parts of macroTask/DomEvents patch by setting following flags @@ -59,7 +49,7 @@ import 'core-js/es'; import 'core-js/features/reflect'; -import 'zone.js/dist/zone'; // Included with Angular CLI. +import 'zone.js'; // Included with Angular CLI. import 'reflect-metadata'; diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 4c631a294a..3b06efb9d5 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -6,10 +6,15 @@ $sidebar-items-width: 250px !default; $total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width !default; /* Fonts */ -$fa-font-path: "/assets/fonts" !default; +// Starting this url with a caret (^) allows it to be a relative path based on UI's deployment path +// See https://github.com/angular/angular-cli/issues/12797#issuecomment-598534241 +$fa-font-path: "^assets/fonts" !default; /* Images */ $image-path: "../assets/images" !default; +// enable-responsive-font-sizes allows text to scale more naturally across device and viewport sizes +$enable-responsive-font-sizes: true; + /** Bootstrap Variables **/ /* Colors */ $gray-700: #495057 !default; // Bootstrap $gray-700 diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 66e0e87f93..2000e007a0 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -45,7 +45,7 @@ --ds-edit-item-metadata-field-width: 190px; --ds-edit-item-language-field-width: 43px; - --ds-thumbnail-max-width: 175px; + --ds-thumbnail-max-width: 125px; --ds-thumbnail-placeholder-background: #{$gray-100}; --ds-thumbnail-placeholder-border: 1px solid #{$gray-300}; --ds-thumbnail-placeholder-color: #{lighten($gray-800, 7%)}; @@ -85,4 +85,6 @@ --ds-slider-handle-width: 18px; --ds-search-form-scope-max-width: 150px; + + --ds-gap: 0.25rem; } diff --git a/src/styles/_functions.scss b/src/styles/_functions.scss index 4445b89727..37176f954a 100644 --- a/src/styles/_functions.scss +++ b/src/styles/_functions.scss @@ -1,12 +1,14 @@ +@use 'sass:math'; + @function calculateRem($size) { - $remSize: $size / 16px; + $remSize: math.div($size, 16px); @return $remSize; } @function strip-unit($number) { @if type-of($number) == 'number' and not unitless($number) { - @return $number / ($number * 0 + 1); + @return math.div($number , ($number * 0 + 1)); } @return $number; } diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index e337539c15..930384cf64 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -92,3 +92,115 @@ ngb-modal-backdrop { hyphens: auto; } + +.researcher-profile-switch button:focus{ + outline: none !important; +} +.researcher-profile-switch .switch.checked{ + color: #fff; +} + +/* Replicate default spacing look ~ preserveWhitespace=true + To be used e.g. on a div containing buttons that should have a bit of spacing in between + */ +.space-children-mr > :not(:last-child) { + margin-right: var(--ds-gap); +} + +/* Complement .space-children-mr when spaced elements are not on the same level */ +.mr-gap { + margin-right: var(--ds-gap); +} + +.ml-gap { + margin-left: var(--ds-gap); +} + +.custom-accordion .card-header button { + -webkit-box-shadow: none!important; + box-shadow: none!important; + width: 100%; +} +.custom-accordion .card:first-of-type { + border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color) !important; + border-bottom-left-radius: var(--bs-card-border-radius) !important; + border-bottom-right-radius: var(--bs-card-border-radius) !important; +} + +ds-dynamic-form-control-container.d-none { + /* Ensures that form-control containers hidden and disabled by type binding collapse and let other fields in + the same row expand accordingly + */ + visibility: collapse; +} + +/* Used for dso administrative functionality */ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} + +.preserve-line-breaks { + white-space: pre-line; +} + +/* Thumbnail styles */ +.hide-placeholder-text { + .thumbnail-placeholder { + color: transparent !important; + } +} + +/* Used to hide the thumbnail column in modals. */ +.hide-modal-thumbnail-column { + .modal-body ds-listable-object-component-loader div.row > div:first-child { + display: none; + } + .modal-body ds-listable-object-component-loader div.row > div:nth-child(2) { + flex: 0 0 100%; + max-width: 100%; + } +} + +/* The font sizes used in "no thumbnail" placeholder */ +.thumb-font-0 { + .thumbnail-placeholder { + @media screen and (max-width: map-get($grid-breakpoints, lg)) { + font-size: 0.7rem; + padding: 0.2rem; + } + @media screen and (max-width: map-get($grid-breakpoints, sm)) { + font-size: 0.6rem; + padding: 0.1rem; + } + font-size: 0.4rem; + padding: 0.1rem; + } +} +.thumb-font-1 { + .thumbnail-placeholder { + @media screen and (max-width: map-get($grid-breakpoints, sm)) { + font-size: 0.9rem; + padding: 0.1rem; + } + @media screen and (max-width: 950px) { + font-size: 0.4rem; + padding: 0.1rem; + } + font-size: 0.5rem; + padding: 0.125rem; + } +} +.thumb-font-2 { + .thumbnail-placeholder { + font-size: 0.9rem; + padding: 0.125rem; + } +} +.thumb-font-3 { + .thumbnail-placeholder { + font-size: 1.25rem; + padding: 0.5rem; + } +} + + diff --git a/src/styles/_truncatable-part.component.scss b/src/styles/_truncatable-part.component.scss index b938f3a199..d9158a6751 100644 --- a/src/styles/_truncatable-part.component.scss +++ b/src/styles/_truncatable-part.component.scss @@ -19,7 +19,7 @@ min-width: 75px; max-width: 150px; height: $height; - background: linear-gradient(to right, rgba(255, 255, 255, 0), $bg 70%); + background: linear-gradient(to right, rgba(255, 255, 255, 0), rgba($bg, 1) 70%); pointer-events: none; } } @@ -61,6 +61,15 @@ $h4-factor: strip-unit($h4-font-size); .ds-hover .clamp-primary-#{$i} { @include clamp-with-titles($i, darken($primary, 10%)); } + + .clamp-light-#{$i} { + @include clamp-with-titles($i, $light); + } + + :focus .clamp-light-#{$i}, + .ds-hover .clamp-light-#{$i} { + @include clamp-with-titles($i, darken($light, 10%)); + } } .clamp-none { diff --git a/src/styles/_vendor.scss b/src/styles/_vendor.scss new file mode 100644 index 0000000000..9d9842b9b3 --- /dev/null +++ b/src/styles/_vendor.scss @@ -0,0 +1,5 @@ +// node_modules imports meant for all the themes + +@import '~node_modules/bootstrap/scss/bootstrap.scss'; +@import '~node_modules/nouislider/distribute/nouislider.min'; +@import '~node_modules/ngx-ui-switch/ui-switch.component.scss'; diff --git a/src/styles/base-theme.scss b/src/styles/base-theme.scss index bde50bcfd7..539f9fe185 100644 --- a/src/styles/base-theme.scss +++ b/src/styles/base-theme.scss @@ -1,6 +1,5 @@ @import './helpers/font_awesome_imports.scss'; -@import '../../node_modules/bootstrap/scss/bootstrap.scss'; -@import '../../node_modules/nouislider/distribute/nouislider.min'; +@import './_vendor.scss'; @import './_custom_variables.scss'; @import './bootstrap_variables_mapping.scss'; @import './_truncatable-part.component.scss'; diff --git a/src/test.ts b/src/test.ts index 16317897b1..477195418b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,19 +1,30 @@ // This file is required by karma.conf.js and loads recursively all the .spec and framework files -import 'zone.js/dist/zone-testing'; +import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; +import { MockStore } from '@ngrx/store/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; declare const require: any; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment( BrowserDynamicTestingModule, - platformBrowserDynamicTesting() + platformBrowserDynamicTesting(), + { teardown: { destroyAfterEach: false } } ); + +jasmine.getEnv().afterEach(() => { + // If store is mocked, reset state after each test (see https://ngrx.io/guide/migration/v13) + getTestBed().inject(MockStore, null)?.resetSelectors(); + // Close any leftover modals + getTestBed().inject(NgbModal, null)?.dismissAll?.(); +}); + // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. diff --git a/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.html b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.scss b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.ts new file mode 100644 index 0000000000..6485ad98e6 --- /dev/null +++ b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { AdminSidebarComponent as BaseComponent } from '../../../../../app/admin/admin-sidebar/admin-sidebar.component'; + +/** + * Component representing the admin sidebar + */ +@Component({ + selector: 'ds-admin-sidebar', + // templateUrl: './admin-sidebar.component.html', + templateUrl: '../../../../../app/admin/admin-sidebar/admin-sidebar.component.html', + // styleUrls: ['./admin-sidebar.component.scss'] + styleUrls: ['../../../../../app/admin/admin-sidebar/admin-sidebar.component.scss'] +}) +export class AdminSidebarComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.html b/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss b/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts new file mode 100644 index 0000000000..9fcf773350 --- /dev/null +++ b/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { BrowseByDatePageComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-date-page/browse-by-date-page.component'; + +@Component({ + selector: 'ds-browse-by-date-page', + // styleUrls: ['./browse-by-date-page.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss'], + // templateUrl: './browse-by-date-page.component.html' + templateUrl: '../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html' +}) + +/** + * Component for determining what Browse-By component to use depending on the metadata (browse ID) provided + */ + +export class BrowseByDatePageComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss b/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts new file mode 100644 index 0000000000..9434ca936d --- /dev/null +++ b/src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { BrowseByMetadataPageComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component'; + +@Component({ + selector: 'ds-browse-by-metadata-page', + // styleUrls: ['./browse-by-metadata-page.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss'], + // templateUrl: './browse-by-metadata-page.component.html' + templateUrl: '../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html' +}) + +/** + * Component for determining what Browse-By component to use depending on the metadata (browse ID) provided + */ + +export class BrowseByMetadataPageComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.html b/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.scss b/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts b/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts new file mode 100644 index 0000000000..ed96a81110 --- /dev/null +++ b/src/themes/custom/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { BrowseByTitlePageComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-title-page/browse-by-title-page.component'; + +@Component({ + selector: 'ds-browse-by-title-page', + // styleUrls: ['./browse-by-title-page.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss'], + // templateUrl: './browse-by-title-page.component.html' + templateUrl: '../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html' +}) + +/** + * Component for determining what Browse-By component to use depending on the metadata (browse ID) provided + */ + +export class BrowseByTitlePageComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.html b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.scss b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts new file mode 100644 index 0000000000..ad9f515dcf --- /dev/null +++ b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { + EditItemTemplatePageComponent as BaseComponent +} from '../../../../../app/collection-page/edit-item-template-page/edit-item-template-page.component'; + +@Component({ + selector: 'ds-edit-item-template-page', + styleUrls: ['./edit-item-template-page.component.scss'], + // templateUrl: './edit-item-template-page.component.html', + templateUrl: '../../../../../app/collection-page/edit-item-template-page/edit-item-template-page.component.html', +}) +/** + * Component for editing the item template of a collection + */ +export class EditItemTemplatePageComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/community-list-page/community-list/community-list.component.html b/src/themes/custom/app/community-list-page/community-list/community-list.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-list-page/community-list/community-list.component.scss b/src/themes/custom/app/community-list-page/community-list/community-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-list-page/community-list/community-list.component.ts b/src/themes/custom/app/community-list-page/community-list/community-list.component.ts new file mode 100644 index 0000000000..61b538d641 --- /dev/null +++ b/src/themes/custom/app/community-list-page/community-list/community-list.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { CommunityListComponent as BaseComponent } from '../../../../../app/community-list-page/community-list/community-list.component'; + +/** + * A tree-structured list of nodes representing the communities, their subCommunities and collections. + * Initially only the page-restricted top communities are shown. + * Each node can be expanded to show its children and all children are also page-limited. + * More pages of a page-limited result can be shown by pressing a show more node/link. + * Which nodes were expanded is kept in the store, so this persists across pages. + */ +@Component({ + selector: 'ds-community-list', + // styleUrls: ['./community-list.component.scss'], + // templateUrl: './community-list.component.html' + templateUrl: '../../../../../app/community-list-page/community-list/community-list.component.html' +}) +export class CommunityListComponent extends BaseComponent {} + diff --git a/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss b/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts new file mode 100644 index 0000000000..a228fe1071 --- /dev/null +++ b/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { CommunityPageSubCollectionListComponent as BaseComponent } + from '../../../../../app/community-page/sub-collection-list/community-page-sub-collection-list.component'; + +@Component({ + selector: 'ds-community-page-sub-collection-list', + // styleUrls: ['./community-page-sub-collection-list.component.scss'], + styleUrls: ['../../../../../app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss'], + // templateUrl: './community-page-sub-collection-list.component.html', + templateUrl: '../../../../../app/community-page/sub-collection-list/community-page-sub-collection-list.component.html' +}) +export class CommunityPageSubCollectionListComponent extends BaseComponent {} diff --git a/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.scss b/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.ts new file mode 100644 index 0000000000..1db265a844 --- /dev/null +++ b/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { CommunityPageSubCommunityListComponent as BaseComponent } + from '../../../../../app/community-page/sub-community-list/community-page-sub-community-list.component'; + +@Component({ + selector: 'ds-community-page-sub-community-list', + // styleUrls: ['./community-page-sub-community-list.component.scss'], + styleUrls: ['../../../../../app/community-page/sub-community-list/community-page-sub-community-list.component.scss'], + // templateUrl: './community-page-sub-community-list.component.html', + templateUrl: '../../../../../app/community-page/sub-community-list/community-page-sub-community-list.component.html' +}) +export class CommunityPageSubCommunityListComponent extends BaseComponent {} diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts new file mode 100644 index 0000000000..a9f23c25f6 --- /dev/null +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + JournalIssueComponent as BaseComponent +} from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component'; +import { Context } from '../../../../../../../app/core/shared/context.model'; + +@listableObjectComponent('JournalIssue', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-journal-issue', + // styleUrls: ['./journal-issue.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss'], + // templateUrl: './journal-issue.component.html', + templateUrl: '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html', +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Issue + */ +export class JournalIssueComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts new file mode 100644 index 0000000000..1a190dc2e8 --- /dev/null +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + JournalVolumeComponent as BaseComponent +} from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component'; +import { Context } from '../../../../../../../app/core/shared/context.model'; + +@listableObjectComponent('JournalVolume', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-journal-volume', + // styleUrls: ['./journal-volume.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss'], + // templateUrl: './journal-volume.component.html', + templateUrl: '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html', +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Volume + */ +export class JournalVolumeComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts new file mode 100644 index 0000000000..7b64c1a35d --- /dev/null +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + JournalComponent as BaseComponent +} from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component'; +import { Context } from '../../../../../../../app/core/shared/context.model'; + +@listableObjectComponent('Journal', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-journal', + // styleUrls: ['./journal.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component.scss'], + // templateUrl: './journal.component.html', + templateUrl: '../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component.html', +}) +/** + * The component for displaying metadata and relations of an item of the type Journal + */ +export class JournalComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts b/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts index e3e5ac8d19..50e196bfaf 100644 --- a/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { EndUserAgreementComponent as BaseComponent } from '../../../../../app/info/end-user-agreement/end-user-agreement.component'; @Component({ - selector: 'ds-home-news', + selector: 'ds-end-user-agreement', // styleUrls: ['./end-user-agreement.component.scss'], styleUrls: ['../../../../../app/info/end-user-agreement/end-user-agreement.component.scss'], // templateUrl: './end-user-agreement.component.html' diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts new file mode 100644 index 0000000000..d6d7c4b8fb --- /dev/null +++ b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { + ItemMetadataComponent as BaseComponent +} from '../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component'; + +@Component({ + selector: 'ds-item-metadata', + // styleUrls: ['./item-metadata.component.scss'], + styleUrls: ['../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component.scss'], + // templateUrl: './item-metadata.component.html', + templateUrl: '../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component.html', +}) +/** + * Component for displaying an item's metadata edit page + */ +export class ItemMetadataComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.scss b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts new file mode 100644 index 0000000000..1d1676e92f --- /dev/null +++ b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Item } from '../../../../../../../app/core/shared/item.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { + UntypedItemComponent as BaseComponent +} from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; + +/** + * Component that represents an untyped Item page + */ +@listableObjectComponent(Item, ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-untyped-item', + // styleUrls: ['./untyped-item.component.scss'], + styleUrls: ['../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component.scss'], + // templateUrl: './untyped-item.component.html', + templateUrl: '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UntypedItemComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts new file mode 100644 index 0000000000..3e11271bf0 --- /dev/null +++ b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { + ExpandableNavbarSectionComponent as BaseComponent +} from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component'; +import { slide } from '../../../../../app/shared/animations/slide'; +import { rendersSectionForMenu } from '../../../../../app/shared/menu/menu-section.decorator'; +import { MenuID } from '../../../../../app/shared/menu/menu-id.model'; + +/** + * Represents an expandable section in the navbar + */ +@Component({ + selector: 'ds-expandable-navbar-section', + // templateUrl: './expandable-navbar-section.component.html', + templateUrl: '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.html', + // styleUrls: ['./expandable-navbar-section.component.scss'], + styleUrls: ['../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss'], + animations: [slide] +}) +@rendersSectionForMenu(MenuID.PUBLIC, true) +export class ExpandableNavbarSectionComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/search-navbar/search-navbar.component.html b/src/themes/custom/app/search-navbar/search-navbar.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/search-navbar/search-navbar.component.scss b/src/themes/custom/app/search-navbar/search-navbar.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/search-navbar/search-navbar.component.ts b/src/themes/custom/app/search-navbar/search-navbar.component.ts new file mode 100644 index 0000000000..3e4da0af7e --- /dev/null +++ b/src/themes/custom/app/search-navbar/search-navbar.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { SearchNavbarComponent as BaseComponent } from '../../../../app/search-navbar/search-navbar.component'; + +@Component({ + selector: 'ds-search-navbar', + // styleUrls: ['./search-navbar.component.scss'], + styleUrls: ['../../../../app/search-navbar/search-navbar.component.scss'], + // templateUrl: './search-navbar.component.html' + templateUrl: '../../../../app/search-navbar/search-navbar.component.html' +}) +export class SearchNavbarComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/search-page/search-page.component.ts b/src/themes/custom/app/search-page/search-page.component.ts index e8973975b5..44314b7183 100644 --- a/src/themes/custom/app/search-page/search-page.component.ts +++ b/src/themes/custom/app/search-page/search-page.component.ts @@ -1,11 +1,19 @@ import { Component } from '@angular/core'; import { SearchPageComponent as BaseComponent } from '../../../../app/search-page/search-page.component'; +import { SEARCH_CONFIG_SERVICE } from '../../../../app/my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../app/core/shared/search/search-configuration.service'; @Component({ selector: 'ds-search-page', // styleUrls: ['./search-page.component.scss'], // templateUrl: './search-page.component.html' - templateUrl: '../../../../app/search-page/search-page.component.html' + templateUrl: '../../../../app/search-page/search-page.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] }) /** diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts new file mode 100644 index 0000000000..af54aacd44 --- /dev/null +++ b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { + AuthNavMenuComponent as BaseComponent, +} from '../../../../../app/shared/auth-nav-menu/auth-nav-menu.component'; +import { fadeInOut, fadeOut } from '../../../../../app/shared/animations/fade'; + +/** + * Component representing the {@link AuthNavMenuComponent} of a page + */ +@Component({ + selector: 'ds-auth-nav-menu', + // templateUrl: 'auth-nav-menu.component.html', + templateUrl: '../../../../../app/shared/auth-nav-menu/auth-nav-menu.component.html', + // styleUrls: ['auth-nav-menu.component.scss'], + styleUrls: ['../../../../../app/shared/auth-nav-menu/auth-nav-menu.component.scss'], + animations: [fadeInOut, fadeOut] +}) +export class AuthNavMenuComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.html b/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.scss b/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.ts new file mode 100644 index 0000000000..4fdbd9125b --- /dev/null +++ b/src/themes/custom/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -0,0 +1,15 @@ +import { + CollectionDropdownComponent as BaseComponent +} from '../../../../../app/shared/collection-dropdown/collection-dropdown.component'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-collection-dropdown', + templateUrl: '../../../../../app/shared/collection-dropdown/collection-dropdown.component.html', + // templateUrl: './collection-dropdown.component.html', + styleUrls: ['../../../../../app/shared/collection-dropdown/collection-dropdown.component.scss'] + // styleUrls: ['./collection-dropdown.component.scss'] +}) +export class CollectionDropdownComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html b/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.scss b/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts new file mode 100644 index 0000000000..c6805175b0 --- /dev/null +++ b/src/themes/custom/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ComcolPageBrowseByComponent as BaseComponent} from '../../../../../app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component'; + +/** + * A component to display the "Browse By" section of a Community or Collection page + * It expects the ID of the Community or Collection as input to be passed on as a scope + */ +@Component({ + selector: 'ds-comcol-page-browse-by', + // styleUrls: ['./comcol-page-browse-by.component.scss'], + styleUrls: ['../../../../../app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.scss'], + // templateUrl: './comcol-page-browse-by.component.html' + templateUrl: '../../../../../app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html' +}) +export class ComcolPageBrowseByComponent extends BaseComponent {} diff --git a/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.html b/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.scss b/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.ts b/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.ts new file mode 100644 index 0000000000..ef68f1538f --- /dev/null +++ b/src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { ComcolPageHandleComponent as BaseComponent} from '../../../../../app/shared/comcol/comcol-page-handle/comcol-page-handle.component'; + + +/** + * This component builds a URL from the value of "handle" + */ + +@Component({ + selector: 'ds-comcol-page-handle', + // templateUrl: './comcol-page-handle.component.html', + templateUrl: '../../../../../app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html', + // styleUrls: ['./comcol-page-handle.component.scss'], + styleUrls: ['../../../../../app/shared/comcol/comcol-page-handle/comcol-page-handle.component.scss'], +}) + + +export class ComcolPageHandleComponent extends BaseComponent {} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts new file mode 100644 index 0000000000..22d40ff539 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + CreateCollectionParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; + +@Component({ + selector: 'ds-create-collection-parent-selector', + // styleUrls: ['./create-collection-parent-selector.component.scss'], + // templateUrl: './create-collection-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class CreateCollectionParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html new file mode 100644 index 0000000000..4a22672988 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -0,0 +1,19 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss new file mode 100644 index 0000000000..0daf4cfa5f --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss @@ -0,0 +1,3 @@ +#create-community-or-separator { + top: 0; +} \ No newline at end of file diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts new file mode 100644 index 0000000000..8b28ee1bb8 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { + CreateCommunityParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; + +@Component({ + selector: 'ds-create-community-parent-selector', + // styleUrls: ['./create-community-parent-selector.component.scss'], + styleUrls: ['../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss'], + // templateUrl: './create-community-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html', +}) +export class CreateCommunityParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html new file mode 100644 index 0000000000..664aef95c0 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -0,0 +1,15 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts new file mode 100644 index 0000000000..f8e3401454 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -0,0 +1,13 @@ +import {Component} from '@angular/core'; +import { + CreateItemParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; + +@Component({ + selector: 'ds-create-item-parent-selector', + // styleUrls: ['./create-item-parent-selector.component.scss'], + // templateUrl: './create-item-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html', +}) +export class CreateItemParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts new file mode 100644 index 0000000000..8f4a8dd5cd --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditCollectionSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; + +@Component({ + selector: 'ds-edit-collection-selector', + // styleUrls: ['./edit-collection-selector.component.scss'], + // templateUrl: './edit-collection-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditCollectionSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts new file mode 100644 index 0000000000..79d52fc350 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditCommunitySelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; + +@Component({ + selector: 'ds-edit-item-selector', + // styleUrls: ['./edit-community-selector.component.scss'], + // templateUrl: './edit-community-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditCommunitySelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts new file mode 100644 index 0000000000..398dbc933c --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditItemSelectorComponent as BaseComponent +} from 'src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; + +@Component({ + selector: 'ds-edit-item-selector', + // styleUrls: ['./edit-item-selector.component.scss'], + // templateUrl: './edit-item-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditItemSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html b/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss b/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts b/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts new file mode 100644 index 0000000000..cd1de5d159 --- /dev/null +++ b/src/themes/custom/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts @@ -0,0 +1,15 @@ +import { + ExternalSourceEntryImportModalComponent as BaseComponent +} from '../../../../../../../../../../app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-external-source-entry-import-modal', + styleUrls: ['../../../../../../../../../../app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss'], + // styleUrls: ['./external-source-entry-import-modal.component.scss'], + templateUrl: '../../../../../../../../../../app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html', + // templateUrl: './external-source-entry-import-modal.component.html' +}) +export class ExternalSourceEntryImportModalComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/shared/loading/loading.component.html b/src/themes/custom/app/shared/loading/loading.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/loading/loading.component.scss b/src/themes/custom/app/shared/loading/loading.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/loading/loading.component.ts b/src/themes/custom/app/shared/loading/loading.component.ts new file mode 100644 index 0000000000..fb1a291dc0 --- /dev/null +++ b/src/themes/custom/app/shared/loading/loading.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { LoadingComponent as BaseComponent } from '../../../../../app/shared/loading/loading.component'; + +@Component({ + selector: 'ds-loading', + styleUrls: ['../../../../../app/shared/loading/loading.component.scss'], + // styleUrls: ['./loading.component.scss'], + templateUrl: '../../../../../app/shared/loading/loading.component.html' + // templateUrl: './loading.component.html' +}) +export class LoadingComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.html b/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.scss b/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.ts b/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.ts new file mode 100644 index 0000000000..78ac59e7b8 --- /dev/null +++ b/src/themes/custom/app/shared/object-list/collection-list-element/collection-list-element.component.ts @@ -0,0 +1,27 @@ +import {Component} from '@angular/core'; + +import {Collection} from '../../../../../../app/core/shared/collection.model'; +import { + CollectionListElementComponent as BaseComponent +} from '../../../../../../app/shared/object-list/collection-list-element/collection-list-element.component'; +import {ViewMode} from '../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import {Context} from '../../../../../../app/core/shared/context.model'; + +@listableObjectComponent(Collection, ViewMode.ListElement, Context.Any, 'custom') + +@Component({ + selector: 'ds-collection-list-element', + // styleUrls: ['./collection-list-element.component.scss'], + styleUrls: ['../../../../../../app/shared/object-list/collection-list-element/collection-list-element.component.scss'], + // templateUrl: './collection-list-element.component.html' + templateUrl: '../../../../../../app/shared/object-list/collection-list-element/collection-list-element.component.html' +}) +/** + * Component representing list element for a collection + */ +export class CollectionListElementComponent extends BaseComponent {} + + diff --git a/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.html b/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.scss b/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.ts b/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.ts new file mode 100644 index 0000000000..24e2cdcfa1 --- /dev/null +++ b/src/themes/custom/app/shared/object-list/community-list-element/community-list-element.component.ts @@ -0,0 +1,23 @@ +import {Component} from '@angular/core'; + +import { Community } from '../../../../../../app/core/shared/community.model'; +import { + CommunityListElementComponent as BaseComponent +} from '../../../../../../app/shared/object-list/community-list-element/community-list-element.component'; +import { ViewMode } from '../../../../../../app/core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import {Context} from '../../../../../../app/core/shared/context.model'; + +@listableObjectComponent(Community, ViewMode.ListElement, Context.Any, 'custom') + +@Component({ + selector: 'ds-community-list-element', + // styleUrls: ['./community-list-element.component.scss'], + styleUrls: ['../../../../../../app/shared/object-list/community-list-element/community-list-element.component.scss'], + // templateUrl: './community-list-element.component.html' + templateUrl: '../../../../../../app/shared/object-list/community-list-element/community-list-element.component.html' +}) +/** + * Component representing a list element for a community + */ +export class CommunityListElementComponent extends BaseComponent {} diff --git a/src/themes/custom/app/shared/object-list/object-list.component.html b/src/themes/custom/app/shared/object-list/object-list.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/object-list/object-list.component.scss b/src/themes/custom/app/shared/object-list/object-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/object-list/object-list.component.ts b/src/themes/custom/app/shared/object-list/object-list.component.ts new file mode 100644 index 0000000000..49f464610f --- /dev/null +++ b/src/themes/custom/app/shared/object-list/object-list.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { ObjectListComponent as BaseComponent} from '../../../../../app/shared/object-list/object-list.component'; + +/** + * A component to display the "Browse By" section of a Community or Collection page + * It expects the ID of the Community or Collection as input to be passed on as a scope + */ +@Component({ + selector: 'ds-object-list', + // styleUrls: ['./object-list.component.scss'], + styleUrls: ['../../../../../app/shared/object-list/object-list.component.scss'], + // templateUrl: 'object-list.component.html' + templateUrl: '../../../../../app/shared/object-list/object-list.component.html' +}) + +export class ObjectListComponent extends BaseComponent {} diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.html b/src/themes/custom/app/shared/search/search-results/search-results.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.scss b/src/themes/custom/app/shared/search/search-results/search-results.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.ts b/src/themes/custom/app/shared/search/search-results/search-results.component.ts new file mode 100644 index 0000000000..5084d4d405 --- /dev/null +++ b/src/themes/custom/app/shared/search/search-results/search-results.component.ts @@ -0,0 +1,17 @@ +import { SearchResultsComponent as BaseComponent } from '../../../../../../app/shared/search/search-results/search-results.component'; +import { Component } from '@angular/core'; +import { fadeIn, fadeInOut } from '../../../../../../app/shared/animations/fade'; + +@Component({ + selector: 'ds-search-results', + // templateUrl: './search-results.component.html', + templateUrl: '../../../../../../app/shared/search/search-results/search-results.component.html', + // styleUrls: ['./search-results.component.scss'], + animations: [ + fadeIn, + fadeInOut + ] +}) +export class SearchResultsComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/shared/search/search-settings/search-settings.component.html b/src/themes/custom/app/shared/search/search-settings/search-settings.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/search/search-settings/search-settings.component.scss b/src/themes/custom/app/shared/search/search-settings/search-settings.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/search/search-settings/search-settings.component.ts b/src/themes/custom/app/shared/search/search-settings/search-settings.component.ts new file mode 100644 index 0000000000..e17c2425b5 --- /dev/null +++ b/src/themes/custom/app/shared/search/search-settings/search-settings.component.ts @@ -0,0 +1,31 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE_ATMIRE and NOTICE_ATMIRE files at the root of the source + * tree and available online at + * + * https://www.atmire.com/software-license/ + */ +import { Component } from '@angular/core'; +import { + SearchSettingsComponent as BaseComponent, +} from '../../../../../../app/shared/search/search-settings/search-settings.component'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../app/my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../app/core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-search-settings', + // styleUrls: ['./search-settings.component.scss'], + styleUrls: ['../../../../../../app/shared/search/search-settings/search-settings.component.scss'], + // templateUrl: './search-settings.component.html', + templateUrl: '../../../../../../app/shared/search/search-settings/search-settings.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] + +}) + +export class SearchSettingsComponent extends BaseComponent {} diff --git a/src/themes/custom/eager-theme.module.ts b/src/themes/custom/eager-theme.module.ts new file mode 100644 index 0000000000..6bca518092 --- /dev/null +++ b/src/themes/custom/eager-theme.module.ts @@ -0,0 +1,107 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { SharedModule } from '../../app/shared/shared.module'; +import { HomeNewsComponent } from './app/home-page/home-news/home-news.component'; +import { NavbarComponent } from './app/navbar/navbar.component'; +import { SearchNavbarComponent } from './app/search-navbar/search-navbar.component'; +import { HeaderComponent } from './app/header/header.component'; +import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; +import { SearchModule } from '../../app/shared/search/search.module'; +import { RootModule } from '../../app/root.module'; +import { NavbarModule } from '../../app/navbar/navbar.module'; +import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; +import { ItemPageModule } from '../../app/item-page/item-page.module'; +import { FooterComponent } from './app/footer/footer.component'; +import { JournalComponent } from './app/entity-groups/journal-entities/item-pages/journal/journal.component'; +import { + JournalIssueComponent +} from './app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component'; +import { + JournalVolumeComponent +} from './app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component'; +import { UntypedItemComponent } from './app/item-page/simple/item-types/untyped-item/untyped-item.component'; +import { ItemSharedModule } from '../../app/item-page/item-shared.module'; +import { + CreateCollectionParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { + CreateCommunityParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { + CreateItemParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { + EditCollectionSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { + EditCommunitySelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { + EditItemSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; + +import { CommunityListElementComponent } from './app/shared/object-list/community-list-element/community-list-element.component'; +import { CollectionListElementComponent} from './app/shared/object-list/collection-list-element/collection-list-element.component'; +import { CollectionDropdownComponent } from './app/shared/collection-dropdown/collection-dropdown.component'; + + +/** + * Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS. + * This will ensure that decorator gets picked up when the app loads + */ +const ENTRY_COMPONENTS = [ + JournalComponent, + JournalIssueComponent, + JournalVolumeComponent, + PublicationComponent, + UntypedItemComponent, + + CommunityListElementComponent, + CollectionListElementComponent, + CollectionDropdownComponent, +]; + +const DECLARATIONS = [ + ...ENTRY_COMPONENTS, + HomeNewsComponent, + HeaderComponent, + HeaderNavbarWrapperComponent, + NavbarComponent, + SearchNavbarComponent, + FooterComponent, + CreateCollectionParentSelectorComponent, + CreateCommunityParentSelectorComponent, + CreateItemParentSelectorComponent, + EditCollectionSelectorComponent, + EditCommunitySelectorComponent, + EditItemSelectorComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + SearchModule, + FormsModule, + RootModule, + NavbarModule, + ItemPageModule, + ItemSharedModule, + ], + declarations: DECLARATIONS, + providers: [ + ...ENTRY_COMPONENTS.map((component) => ({ provide: component })) + ], +}) +/** + * This module is included in the main bundle that gets downloaded at first page load. So it should + * contain only the themed components that have to be available immediately for the first page load, + * and the minimal set of imports required to make them work. Anything you can cut from it will make + * the initial page load faster, but may cause the page to flicker as components that were already + * rendered server side need to be lazy-loaded again client side + * + * Themed EntryComponents should also be added here + */ +export class EagerThemeModule { +} diff --git a/src/themes/custom/entry-components.ts b/src/themes/custom/entry-components.ts deleted file mode 100644 index b518e4cc45..0000000000 --- a/src/themes/custom/entry-components.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; - -export const ENTRY_COMPONENTS = [ - PublicationComponent -]; diff --git a/src/themes/custom/theme.module.ts b/src/themes/custom/lazy-theme.module.ts similarity index 70% rename from src/themes/custom/theme.module.ts rename to src/themes/custom/lazy-theme.module.ts index e2e97b9087..2ff4af7946 100644 --- a/src/themes/custom/theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -28,35 +28,43 @@ import { StatisticsModule } from '../../app/statistics/statistics.module'; import { StoreModule } from '@ngrx/store'; import { StoreRouterConnectingModule } from '@ngrx/router-store'; import { TranslateModule } from '@ngx-translate/core'; -import { HomeNewsComponent } from './app/home-page/home-news/home-news.component'; -import { HomePageComponent } from './app/home-page/home-page.component'; import { HomePageModule } from '../../app/home-page/home-page.module'; -import { RootComponent } from './app/root/root.component'; import { AppModule } from '../../app/app.module'; -import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; import { ItemPageModule } from '../../app/item-page/item-page.module'; import { RouterModule } from '@angular/router'; -import { AccessControlModule } from '../../app/access-control/access-control.module'; +import { CommunityListPageModule } from '../../app/community-list-page/community-list-page.module'; +import { InfoModule } from '../../app/info/info.module'; +import { StatisticsPageModule } from '../../app/statistics-page/statistics-page.module'; +import { CommunityPageModule } from '../../app/community-page/community-page.module'; +import { CollectionPageModule } from '../../app/collection-page/collection-page.module'; +import { SubmissionModule } from '../../app/submission/submission.module'; +import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module'; +import { SearchModule } from '../../app/shared/search/search.module'; +import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module'; +import { ComcolModule } from '../../app/shared/comcol/comcol.module'; +import { RootModule } from '../../app/root.module'; +import { FileSectionComponent } from './app/item-page/simple/field-components/file-section/file-section.component'; +import { HomePageComponent } from './app/home-page/home-page.component'; +import { RootComponent } from './app/root/root.component'; import { BrowseBySwitcherComponent } from './app/browse-by/browse-by-switcher/browse-by-switcher.component'; import { CommunityListPageComponent } from './app/community-list-page/community-list-page.component'; -import { CommunityListPageModule } from '../../app/community-list-page/community-list-page.module'; import { SearchPageComponent } from './app/search-page/search-page.component'; -import { InfoModule } from '../../app/info/info.module'; +import { ConfigurationSearchPageComponent } from './app/search-page/configuration-search-page.component'; import { EndUserAgreementComponent } from './app/info/end-user-agreement/end-user-agreement.component'; import { PageNotFoundComponent } from './app/pagenotfound/pagenotfound.component'; import { ObjectNotFoundComponent } from './app/lookup-by-id/objectnotfound/objectnotfound.component'; import { ForbiddenComponent } from './app/forbidden/forbidden.component'; import { PrivacyComponent } from './app/info/privacy/privacy.component'; -import { CollectionStatisticsPageComponent } from './app/statistics-page/collection-statistics-page/collection-statistics-page.component'; -import { CommunityStatisticsPageComponent } from './app/statistics-page/community-statistics-page/community-statistics-page.component'; -import { StatisticsPageModule } from '../../app/statistics-page/statistics-page.module'; +import { + CollectionStatisticsPageComponent +} from './app/statistics-page/collection-statistics-page/collection-statistics-page.component'; +import { + CommunityStatisticsPageComponent +} from './app/statistics-page/community-statistics-page/community-statistics-page.component'; import { ItemStatisticsPageComponent } from './app/statistics-page/item-statistics-page/item-statistics-page.component'; import { SiteStatisticsPageComponent } from './app/statistics-page/site-statistics-page/site-statistics-page.component'; import { CommunityPageComponent } from './app/community-page/community-page.component'; import { CollectionPageComponent } from './app/collection-page/collection-page.component'; -import { CommunityPageModule } from '../../app/community-page/community-page.module'; -import { CollectionPageModule } from '../../app/collection-page/collection-page.module'; -import { ConfigurationSearchPageComponent } from './app/search-page/configuration-search-page.component'; import { ItemPageComponent } from './app/item-page/simple/item-page.component'; import { FullItemPageComponent } from './app/item-page/full/full-item-page.component'; import { LoginPageComponent } from './app/login-page/login-page.component'; @@ -66,31 +74,54 @@ import { ForgotEmailComponent } from './app/forgot-password/forgot-password-emai import { ForgotPasswordFormComponent } from './app/forgot-password/forgot-password-form/forgot-password-form.component'; import { ProfilePageComponent } from './app/profile-page/profile-page.component'; import { RegisterEmailComponent } from './app/register-page/register-email/register-email.component'; -import { SubmissionEditComponent } from './app/submission/edit/submission-edit.component'; -import { SubmissionImportExternalComponent } from './app/submission/import-external/submission-import-external.component'; -import { SubmissionSubmitComponent } from './app/submission/submit/submission-submit.component'; import { MyDSpacePageComponent } from './app/my-dspace-page/my-dspace-page.component'; -import { WorkflowItemSendBackComponent } from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component'; -import { WorkflowItemDeleteComponent } from './app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component'; -import { SubmissionModule } from '../../app/submission/submission.module'; -import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module'; -import { NavbarComponent } from './app/navbar/navbar.component'; -import { HeaderComponent } from './app/header/header.component'; -import { FooterComponent } from './app/footer/footer.component'; +import { SubmissionEditComponent } from './app/submission/edit/submission-edit.component'; +import { + SubmissionImportExternalComponent +} from './app/submission/import-external/submission-import-external.component'; +import { SubmissionSubmitComponent } from './app/submission/submit/submission-submit.component'; +import { WorkflowItemDeleteComponent +} from './app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component'; +import { + WorkflowItemSendBackComponent +} from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component'; import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component'; -import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; -import { FileSectionComponent } from './app/item-page/simple/field-components/file-section/file-section.component'; -import { SearchModule } from '../../app/shared/search/search.module'; -import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module'; -import { ComcolModule } from '../../app/shared/comcol/comcol.module'; import { FeedbackComponent } from './app/info/feedback/feedback.component'; +import { CommunityListComponent } from './app/community-list-page/community-list/community-list.component'; + +import { ComcolPageHandleComponent } from './app/shared/comcol-page-handle/comcol-page-handle.component'; +import { AuthNavMenuComponent } from './app/shared/auth-nav-menu/auth-nav-menu.component'; +import { + ExpandableNavbarSectionComponent +} from './app/navbar/expandable-navbar-section/expandable-navbar-section.component'; +import { ItemMetadataComponent } from './app/item-page/edit-item-page/item-metadata/item-metadata.component'; +import { + EditItemTemplatePageComponent +} from './app/collection-page/edit-item-template-page/edit-item-template-page.component'; +import { LoadingComponent } from './app/shared/loading/loading.component'; +import { SearchResultsComponent } from './app/shared/search/search-results/search-results.component'; +import { AdminSidebarComponent } from './app/admin/admin-sidebar/admin-sidebar.component'; +import { ComcolPageBrowseByComponent } from './app/shared/comcol-page-browse-by/comcol-page-browse-by.component'; +import { SearchSettingsComponent } from './app/shared/search/search-settings/search-settings.component'; +import { + CommunityPageSubCommunityListComponent +} from './app/community-page/sub-community-list/community-page-sub-community-list.component'; +import { + CommunityPageSubCollectionListComponent +} from './app/community-page/sub-collection-list/community-page-sub-collection-list.component'; +import { ObjectListComponent } from './app/shared/object-list/object-list.component'; + +import { BrowseByMetadataPageComponent } from './app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './app/browse-by/browse-by-date-page/browse-by-date-page.component'; +import { BrowseByTitlePageComponent } from './app/browse-by/browse-by-title-page/browse-by-title-page.component'; +import { + ExternalSourceEntryImportModalComponent +} from './app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; const DECLARATIONS = [ FileSectionComponent, HomePageComponent, - HomeNewsComponent, RootComponent, - PublicationComponent, BrowseBySwitcherComponent, CommunityListPageComponent, SearchPageComponent, @@ -105,6 +136,8 @@ const DECLARATIONS = [ ItemStatisticsPageComponent, SiteStatisticsPageComponent, CommunityPageComponent, + CommunityPageSubCommunityListComponent, + CommunityPageSubCollectionListComponent, CollectionPageComponent, ItemPageComponent, FullItemPageComponent, @@ -121,21 +154,35 @@ const DECLARATIONS = [ SubmissionSubmitComponent, WorkflowItemDeleteComponent, WorkflowItemSendBackComponent, - FooterComponent, - HeaderComponent, - NavbarComponent, - HeaderNavbarWrapperComponent, BreadcrumbsComponent, - FeedbackComponent + FeedbackComponent, + CommunityListComponent, + ComcolPageHandleComponent, + AuthNavMenuComponent, + ExpandableNavbarSectionComponent, + ItemMetadataComponent, + EditItemTemplatePageComponent, + LoadingComponent, + SearchResultsComponent, + AdminSidebarComponent, + SearchSettingsComponent, + ComcolPageBrowseByComponent, + ObjectListComponent, + BrowseByMetadataPageComponent, + BrowseByDatePageComponent, + BrowseByTitlePageComponent, + ExternalSourceEntryImportModalComponent, + + ]; @NgModule({ imports: [ - AccessControlModule, AdminRegistriesModule, AdminSearchModule, AdminWorkflowModuleModule, AppModule, + RootModule, BitstreamFormatsModule, BrowseByModule, CollectionFormModule, @@ -176,9 +223,12 @@ const DECLARATIONS = [ SearchModule, FormsModule, ResourcePoliciesModule, - ComcolModule + ComcolModule, ], - declarations: DECLARATIONS + declarations: DECLARATIONS, + exports: [ + CommunityPageSubCollectionListComponent + ] }) /** @@ -188,5 +238,5 @@ const DECLARATIONS = [ * It is purposefully not exported, it should never be imported anywhere else, its only purpose is * to give lazily loaded components a context in which they can be compiled successfully */ -class ThemeModule { +class LazyThemeModule { } diff --git a/src/themes/custom/styles/theme.scss b/src/themes/custom/styles/theme.scss index 35810b15a6..05c96f3372 100644 --- a/src/themes/custom/styles/theme.scss +++ b/src/themes/custom/styles/theme.scss @@ -4,8 +4,7 @@ @import '../../../styles/_variables.scss'; @import '../../../styles/_mixins.scss'; @import '../../../styles/helpers/font_awesome_imports.scss'; -@import '../../../../node_modules/bootstrap/scss/bootstrap.scss'; -@import '../../../../node_modules/nouislider/distribute/nouislider.min'; +@import '../../../styles/_vendor.scss'; @import '../../../styles/_custom_variables.scss'; @import './_theme_css_variable_overrides.scss'; @import '../../../styles/bootstrap_variables_mapping.scss'; diff --git a/src/themes/dspace/app/header/header.component.html b/src/themes/dspace/app/header/header.component.html index cf691ea6c4..0fd4d42518 100644 --- a/src/themes/dspace/app/header/header.component.html +++ b/src/themes/dspace/app/header/header.component.html @@ -8,7 +8,7 @@
- +