diff --git a/.eslintrc.json b/.eslintrc.json index af1b97849b6..1151e6831be 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,15 @@ "eslint-plugin-deprecation", "unused-imports", "eslint-plugin-lodash", - "eslint-plugin-jsonc" + "eslint-plugin-jsonc", + "eslint-plugin-rxjs", + "eslint-plugin-simple-import-sort", + "eslint-plugin-import-newlines", + "dspace-angular-ts", + "dspace-angular-html" + ], + "ignorePatterns": [ + "lint/test/fixture" ], "overrides": [ { @@ -18,7 +26,8 @@ "parserOptions": { "project": [ "./tsconfig.json", - "./cypress/tsconfig.json" + "./cypress/tsconfig.json", + "./lint/tsconfig.json" ], "createDefaultProgram": true }, @@ -27,17 +36,32 @@ "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:@angular-eslint/recommended", - "plugin:@angular-eslint/template/process-inline-templates" + "plugin:@angular-eslint/template/process-inline-templates", + "plugin:rxjs/recommended" ], "rules": { + "indent": [ + "error", + 2, + { + "SwitchCase": 1, + "ignoredNodes": [ + "ClassBody.body > PropertyDefinition[decorators.length > 0] > .key" + ] + } + ], "max-classes-per-file": [ "error", 1 ], "comma-dangle": [ - "off", + "error", "always-multiline" ], + "object-curly-spacing": [ + "error", + "always" + ], "eol-last": [ "error", "always" @@ -104,15 +128,13 @@ "allowTernary": true } ], - "prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint) + "prefer-const": "error", + "no-case-declarations": "error", + "no-extra-boolean-cast": "error", "prefer-spread": "off", "no-underscore-dangle": "off", - - // todo: disabled rules from eslint:recommended, consider re-enabling & fixing "no-prototype-builtins": "off", "no-useless-escape": "off", - "no-case-declarations": "off", - "no-extra-boolean-cast": "off", "@angular-eslint/directive-selector": [ "error", @@ -139,10 +161,10 @@ } ], "@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", + "@angular-eslint/use-lifecycle-interface": "error", "@typescript-eslint/no-inferrable-types":[ "error", @@ -183,7 +205,7 @@ ], "@typescript-eslint/type-annotation-spacing": "error", "@typescript-eslint/unified-signatures": "error", - "@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable + "@typescript-eslint/ban-types": "error", "@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/restrict-plus-operands": "warn", @@ -200,17 +222,65 @@ "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-base-to-string": [ + "error", + { + "ignoredTypeNames": [ + "ResourceType", + "Error" + ] + } + ], "deprecation/deprecation": "warn", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", "import/order": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", "import/no-deprecated": "warn", "import/no-namespace": "error", + "import-newlines/enforce": [ + "error", + { + "items": 1, + "semi": true, + "forceSingleLine": true + } + ], + "unused-imports/no-unused-imports": "error", "lodash/import-scope": [ "error", "method" - ] + ], + + "rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases + + // Custom DSpace Angular rules + "dspace-angular-ts/themed-component-classes": "error", + "dspace-angular-ts/themed-component-selectors": "error", + "dspace-angular-ts/themed-component-usages": "error" + } + }, + { + "files": [ + "*.spec.ts" + ], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./cypress/tsconfig.json" + ], + "createDefaultProgram": true + }, + "rules": { + "prefer-const": "off", + + // Custom DSpace Angular rules + "dspace-angular-ts/themed-component-usages": "error" } }, { @@ -221,9 +291,9 @@ "plugin:@angular-eslint/template/recommended" ], "rules": { - // todo: re-enable & fix errors - "@angular-eslint/template/no-negated-async": "off", - "@angular-eslint/template/eqeqeq": "off" + // Custom DSpace Angular rules + "dspace-angular-html/themed-component-usages": "error", + "dspace-angular-html/no-disabled-attribute-on-button": "error" } }, { @@ -231,10 +301,13 @@ "*.json5" ], "extends": [ - "plugin:jsonc/recommended-with-jsonc" + "plugin:jsonc/recommended-with-json5" ], "rules": { - "no-irregular-whitespace": "error", + // The ESLint core no-irregular-whitespace rule doesn't work well in JSON + // See: https://ota-meshi.github.io/eslint-plugin-jsonc/rules/no-irregular-whitespace.html + "no-irregular-whitespace": "off", + "jsonc/no-irregular-whitespace": "error", "no-trailing-spaces": "error", "jsonc/comma-dangle": [ "error", diff --git a/.gitattributes b/.gitattributes index 406640bfcc9..b5ad93b1bc6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,4 +13,7 @@ *.css eol=lf *.scss eol=lf *.html eol=lf -*.svg eol=lf \ No newline at end of file +*.svg eol=lf + +# Generated documentation should have LF line endings to reduce git noise +docs/lint/**/*.md eol=lf \ No newline at end of file diff --git a/.github/disabled-workflows/pull_request_opened.yml b/.github/disabled-workflows/pull_request_opened.yml deleted file mode 100644 index 0dc718c0b9a..00000000000 --- a/.github/disabled-workflows/pull_request_opened.yml +++ /dev/null @@ -1,26 +0,0 @@ -# This workflow runs whenever a new pull request is created -# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs). -# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818 -name: Pull Request opened - -# Only run for newly opened PRs against the "main" branch -on: - pull_request: - types: [opened] - branches: - - main - -jobs: - automation: - runs-on: ubuntu-latest - steps: - # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards - # See https://github.com/marketplace/actions/pull-request-assigner - - name: Assign PR to creator - uses: thomaseizinger/assign-pr-creator-action@v1.0.0 - # Note, this authentication token is created automatically - # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - # Ignore errors. It is possible the PR was created by someone who cannot be assigned - continue-on-error: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 219074780e3..8e3613acae2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,8 @@ name: Build on: [push, pull_request] permissions: - contents: read # to fetch code (actions/checkout) + contents: read # to fetch code (actions/checkout) + packages: read # to fetch private images from GitHub Container Registry (GHCR) jobs: tests: @@ -28,26 +29,33 @@ jobs: DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0 # Tell Cypress to run e2e tests using the same UI URL CYPRESS_BASE_URL: http://127.0.0.1:4000 + # Disable the cookie consent banner in e2e tests to avoid errors because of elements hidden by it + DSPACE_INFO_ENABLECOOKIECONSENTPOPUP: false # 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" # Bump Node heap size (OOM in CI after upgrading to Angular 15) NODE_OPTIONS: '--max-old-space-size=4096' + # Project name to use when running "docker compose" prior to e2e tests + COMPOSE_PROJECT_NAME: 'ci' + # Docker Registry to use for Docker compose scripts below. + # We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub. + DOCKER_REGISTRY: ghcr.io strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [16.x, 18.x] + node-version: [18.x, 20.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v3 + uses: actions/checkout@v4 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} @@ -72,7 +80,7 @@ jobs: id: yarn-cache-dir-path run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Cache Yarn dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: # Cache entire Yarn cache directory (see previous step) path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -83,8 +91,14 @@ jobs: - name: Install Yarn dependencies run: yarn install --frozen-lockfile + - name: Build lint plugins + run: yarn run build:lint + + - name: Run lint plugin tests + run: yarn run test:lint:nobuild + - name: Run lint - run: yarn run lint --quiet + run: yarn run lint:nobuild --quiet - name: Check for circular dependencies run: yarn run check-circ-deps @@ -99,26 +113,34 @@ jobs: # so that it can be shared with the 'codecov' job (see below) # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - name: Upload code coverage report to Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: matrix.node-version == '18.x' with: - name: dspace-angular coverage report + name: coverage-report-${{ matrix.node-version }} path: 'coverage/dspace-angular/lcov.info' retention-days: 14 - # Using docker-compose start backend using CI configuration + # Login to our Docker registry, so that we can access private Docker images using "docker compose" below. + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Using "docker compose" start backend using CI configuration # and load assetstore from a cached copy - name: Start DSpace REST Backend via Docker (for e2e tests) run: | - docker-compose -f ./docker/docker-compose-ci.yml up -d - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + docker compose -f ./docker/docker-compose-ci.yml up -d + docker compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli docker container ls # Run integration tests via Cypress.io # 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@v5 + uses: cypress-io/github-action@v6 with: # Run tests in Chrome, headless mode (default) browser: chrome @@ -133,19 +155,19 @@ jobs: # Cypress always creates a video of all e2e tests (whether they succeeded or failed) # Save those in an Artifact - name: Upload e2e test videos to Artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: - name: e2e-test-videos + name: e2e-test-videos-${{ matrix.node-version }} path: cypress/videos # If e2e tests fail, Cypress creates a screenshot of what happened # Save those in an Artifact - name: Upload e2e test failure screenshots to Artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: - name: e2e-test-screenshots + name: e2e-test-screenshots-${{ matrix.node-version }} path: cypress/screenshots - name: Stop app (in case it stays up after e2e tests) @@ -170,17 +192,120 @@ jobs: # Get homepage and verify that the tag includes "DSpace". # If it does, then SSR is working, as this tag is created by our MetadataService. # This step also prints entire HTML of homepage for easier debugging if grep fails. - - name: Verify SSR (server-side rendering) + - name: Verify SSR (server-side rendering) on Homepage run: | result=$(wget -O- -q http://127.0.0.1:4000/home) echo "$result" echo "$result" | grep -oE "]*>" | grep DSpace + # Get a specific community in our test data and verify that the "

" tag includes "Publications" (the community name). + # If it does, then SSR is working. + - name: Verify SSR on a Community page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/communities/0958c910-2037-42a9-81c7-dca80e3892b4) + echo "$result" + echo "$result" | grep -oE "

]*>[^><]*

" | grep Publications + + # Get a specific collection in our test data and verify that the "

" tag includes "Articles" (the collection name). + # If it does, then SSR is working. + - name: Verify SSR on a Collection page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/collections/282164f5-d325-4740-8dd1-fa4d6d3e7200) + echo "$result" + echo "$result" | grep -oE "

]*>[^><]*

" | grep Articles + + # Get a specific publication in our test data and verify that the tag includes + # the title of this publication. If it does, then SSR is working. + - name: Verify SSR on a Publication page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/publication/6160810f-1e53-40db-81ef-f6621a727398) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "An Economic Model of Mortality Salience" + + # Get a specific person in our test data and verify that the tag includes + # the name of the person. If it does, then SSR is working. + - name: Verify SSR on a Person page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/person/b1b2c768-bda1-448a-a073-fc541e8b24d9) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "Simmons, Cameron" + + # Get a specific project in our test data and verify that the tag includes + # the name of the project. If it does, then SSR is working. + - name: Verify SSR on a Project page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/project/46ccb608-a74c-4bf6-bc7a-e29cc7defea9) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "University Research Fellowship" + + # Get a specific orgunit in our test data and verify that the tag includes + # the name of the orgunit. If it does, then SSR is working. + - name: Verify SSR on an OrgUnit page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/orgunit/9851674d-bd9a-467b-8d84-068deb568ccf) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "Law and Development" + + # Get a specific journal in our test data and verify that the tag includes + # the name of the journal. If it does, then SSR is working. + - name: Verify SSR on a Journal page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/journal/d4af6c3e-53d0-4757-81eb-566f3b45d63a) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "Environmental & Architectural Phenomenology" + + # Get a specific journal volume in our test data and verify that the tag includes + # the name of the volume. If it does, then SSR is working. + - name: Verify SSR on a Journal Volume page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/journalvolume/07c6249f-4bf7-494d-9ce3-6ffdb2aed538) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "Environmental & Architectural Phenomenology Volume 28 (2017)" + + # Get a specific journal issue in our test data and verify that the tag includes + # the name of the issue. If it does, then SSR is working. + - name: Verify SSR on a Journal Issue page + run: | + result=$(wget -O- -q http://127.0.0.1:4000/entities/journalissue/44c29473-5de2-48fa-b005-e5029aa1a50b) + echo "$result" + echo "$result" | grep -oE "]*>" | grep "Environmental & Architectural Phenomenology Vol. 28, No. 1" + + # Verify 301 Handle redirect behavior + # Note: /handle/123456789/260 is the same test Publication used by our e2e tests + - name: Verify 301 redirect from '/handle' URLs + run: | + result=$(wget --server-response --quiet http://127.0.0.1:4000/handle/123456789/260 2>&1 | head -1 | awk '{print $2}') + echo "$result" + [[ "$result" -eq "301" ]] + + # Verify 403 error code behavior + - name: Verify 403 error code from '/403' + run: | + result=$(wget --server-response --quiet http://127.0.0.1:4000/403 2>&1 | head -1 | awk '{print $2}') + echo "$result" + [[ "$result" -eq "403" ]] + + # Verify 404 error code behavior + - name: Verify 404 error code from '/404' and on invalid pages + run: | + result=$(wget --server-response --quiet http://127.0.0.1:4000/404 2>&1 | head -1 | awk '{print $2}') + echo "$result" + result2=$(wget --server-response --quiet http://127.0.0.1:4000/invalidurl 2>&1 | head -1 | awk '{print $2}') + echo "$result2" + [[ "$result" -eq "404" && "$result2" -eq "404" ]] + + # Verify 500 error code behavior + - name: Verify 500 error code from '/500' + run: | + result=$(wget --server-response --quiet http://127.0.0.1:4000/500 2>&1 | head -1 | awk '{print $2}') + echo "$result" + [[ "$result" -eq "500" ]] + - name: Stop running app run: kill -9 $(lsof -t -i:4000) - name: Shutdown Docker containers - run: docker-compose -f ./docker/docker-compose-ci.yml down + run: docker compose -f ./docker/docker-compose-ci.yml down # Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test # job above. This is necessary because Codecov uploads seem to randomly fail at times. @@ -191,11 +316,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Download artifacts from previous 'tests' job - name: Download coverage artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 # Now attempt upload to Codecov using its action. # NOTE: We use a retry action to retry the Codecov upload if it fails the first time. @@ -203,10 +328,15 @@ jobs: # Retry action: https://github.com/marketplace/actions/retry-action # Codecov action: https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io - uses: Wandalen/wretry.action@v1.0.36 + uses: Wandalen/wretry.action@v1.3.0 with: - action: codecov/codecov-action@v3 - # Try upload 5 times max + action: codecov/codecov-action@v4 + # Ensure codecov-action throws an error when it fails to upload + # This allows us to auto-restart the action if an error is thrown + with: | + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + # Try re-running action 5 times max attempt_limit: 5 # Run again in 30 seconds attempt_delay: 30000 diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index 35a2e2d24aa..65cffdfcd9f 100644 --- a/.github/workflows/codescan.yml +++ b/.github/workflows/codescan.yml @@ -5,12 +5,16 @@ # 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. +# Run this code scan for all pushes / PRs to main or maintenance branches. Also run once a week. on: push: - branches: [ main ] + branches: + - main + - 'dspace-**' pull_request: - branches: [ main ] + branches: + - main + - 'dspace-**' # Don't run if PR is only updating static documentation paths-ignore: - '**/*.md' @@ -31,19 +35,19 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. # https://github.com/github/codeql-action - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: javascript # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # Perform GitHub Code Scanning. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 \ No newline at end of file + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9a2c838d83f..bae8c013005 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,9 @@ name: Docker images # Run this Build for all pushes to 'main' or maintenance branches, or tagged releases. # Also run for PRs to ensure PR doesn't break Docker build process +# NOTE: uses "reusable-docker-build.yml" in DSpace/DSpace to actually build each of the Docker images +# https://github.com/DSpace/DSpace/blob/main/.github/workflows/reusable-docker-build.yml +# on: push: branches: @@ -13,108 +16,45 @@ on: pull_request: permissions: - contents: read # to fetch code (actions/checkout) + contents: read # to fetch code (actions/checkout) + packages: write # to write images to GitHub Container Registry (GHCR) jobs: - docker: + ############################################################# + # Build/Push the 'dspace/dspace-angular' image + ############################################################# + dspace-angular: # 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 - env: - # Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action) - # For a new commit on default branch (main), use the literal tag 'dspace-7_x' on Docker image. - # For a new commit on other branches, use the branch name as the tag for Docker image. - # For a new tag, copy that tag name as the tag for Docker image. - IMAGE_TAGS: | - type=raw,value=dspace-7_x,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=tag - # Define default tag "flavor" for docker/metadata-action per - # https://github.com/docker/metadata-action#flavor-input - # 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@v3 - - # https://github.com/docker/setup-buildx-action - - name: Setup Docker Buildx - 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@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - - ############################################### - # Build/Push the 'dspace/dspace-angular' image - ############################################### - # https://github.com/docker/metadata-action - # 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@v4 - with: - images: dspace/dspace-angular - tags: ${{ env.IMAGE_TAGS }} - flavor: ${{ env.TAGS_FLAVOR }} - - # https://github.com/docker/build-push-action - - name: Build and push 'dspace-angular' image - id: docker_build - 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' }} - # Use tags / labels provided by 'docker/metadata-action' above - tags: ${{ steps.meta_build.outputs.tags }} - labels: ${{ steps.meta_build.outputs.labels }} - - ##################################################### - # Build/Push the 'dspace/dspace-angular' image ('-dist' tag) - ##################################################### - # https://github.com/docker/metadata-action - # Get Metadata for docker_build_dist step below - - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular-dist' image - id: meta_build_dist - uses: docker/metadata-action@v4 - with: - images: dspace/dspace-angular - tags: ${{ env.IMAGE_TAGS }} - # As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same - # tagging logic as the primary 'dspace/dspace-angular' image above. - flavor: ${{ env.TAGS_FLAVOR }} - suffix=-dist - - - name: Build and push 'dspace-angular-dist' image - id: docker_build_dist - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile.dist - 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' }} - # Use tags / labels provided by 'docker/metadata-action' above - tags: ${{ steps.meta_build_dist.outputs.tags }} - labels: ${{ steps.meta_build_dist.outputs.labels }} + # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image + uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main + with: + build_id: dspace-angular-dev + image_name: dspace/dspace-angular + dockerfile_path: ./Dockerfile + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + ############################################################# + # Build/Push the 'dspace/dspace-angular' image ('-dist' tag) + ############################################################# + dspace-angular-dist: + # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' + if: github.repository == 'dspace/dspace-angular' + # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image + uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main + with: + build_id: dspace-angular-dist + image_name: dspace/dspace-angular + dockerfile_path: ./Dockerfile.dist + # As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same + # tagging logic as the primary 'dspace/dspace-angular' image above. + tags_flavor: suffix=-dist + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} + # Enable redeploy of sandbox & demo if the branch for this image matches the deployment branch of + # these sites as specified in reusable-docker-build.xml + REDEPLOY_SANDBOX_URL: ${{ secrets.REDEPLOY_SANDBOX_URL }} + REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }} \ No newline at end of file diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml index b4436dca3aa..0a35a6a9504 100644 --- a/.github/workflows/issue_opened.yml +++ b/.github/workflows/issue_opened.yml @@ -16,7 +16,7 @@ jobs: # Only add to project board if issue is flagged as "needs triage" or has no labels # NOTE: By default we flag new issues as "needs triage" in our issue template if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') - uses: actions/add-to-project@v0.5.0 + uses: actions/add-to-project@v1.0.0 # Note, the authentication token below is an ORG level Secret. # It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml index c1396b6f45c..ccc6c401c0b 100644 --- a/.github/workflows/label_merge_conflicts.yml +++ b/.github/workflows/label_merge_conflicts.yml @@ -1,11 +1,12 @@ # This workflow checks open PRs for merge conflicts and labels them when conflicts are found name: Check for merge conflicts -# Run whenever the "main" branch is updated -# NOTE: This means merge conflicts are only checked for when a PR is merged to main. +# Run this for all pushes (i.e. merges) to 'main' or maintenance branches on: push: - branches: [ main ] + branches: + - main + - 'dspace-**' # 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: @@ -24,6 +25,8 @@ jobs: # See: https://github.com/prince-chrismc/label-merge-conflicts-action - name: Auto-label PRs with merge conflicts uses: prince-chrismc/label-merge-conflicts-action@v3 + # Ignore any failures -- may occur (randomly?) for older, outdated PRs. + continue-on-error: true # 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 diff --git a/.github/workflows/port_merged_pull_request.yml b/.github/workflows/port_merged_pull_request.yml new file mode 100644 index 00000000000..857f22755e4 --- /dev/null +++ b/.github/workflows/port_merged_pull_request.yml @@ -0,0 +1,46 @@ +# This workflow will attempt to port a merged pull request to +# the branch specified in a "port to" label (if exists) +name: Port merged Pull Request + +# Only run for merged PRs against the "main" or maintenance branches +# We allow this to run for `pull_request_target` so that github secrets are available +# (This is required when the PR comes from a forked repo) +on: + pull_request_target: + types: [ closed ] + branches: + - main + - 'dspace-**' + +permissions: + contents: write # so action can add comments + pull-requests: write # so action can create pull requests + +jobs: + port_pr: + runs-on: ubuntu-latest + # Don't run on closed *unmerged* pull requests + if: github.event.pull_request.merged + steps: + # Checkout code + - uses: actions/checkout@v4 + # Port PR to other branch (ONLY if labeled with "port to") + # See https://github.com/korthout/backport-action + - name: Create backport pull requests + uses: korthout/backport-action@v2 + with: + # Trigger based on a "port to [branch]" label on PR + # (This label must specify the branch name to port to) + label_pattern: '^port to ([^ ]+)$' + # Title to add to the (newly created) port PR + pull_title: '[Port ${target_branch}] ${pull_title}' + # Description to add to the (newly created) port PR + pull_description: 'Port of #${pull_number} by @${pull_author} to `${target_branch}`.' + # Copy all labels from original PR to (newly created) port PR + # NOTE: The labels matching 'label_pattern' are automatically excluded + copy_labels_pattern: '.*' + # Skip any merge commits in the ported PR. This means only non-merge commits are cherry-picked to the new PR + merge_commits: 'skip' + # Use a personal access token (PAT) to create PR as 'dspace-bot' user. + # A PAT is required in order for the new PR to trigger its own actions (for CI checks) + github_token: ${{ secrets.PR_PORT_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pull_request_opened.yml b/.github/workflows/pull_request_opened.yml new file mode 100644 index 00000000000..bbac52af243 --- /dev/null +++ b/.github/workflows/pull_request_opened.yml @@ -0,0 +1,24 @@ +# This workflow runs whenever a new pull request is created +name: Pull Request opened + +# Only run for newly opened PRs against the "main" or maintenance branches +# We allow this to run for `pull_request_target` so that github secrets are available +# (This is required to assign a PR back to the creator when the PR comes from a forked repo) +on: + pull_request_target: + types: [ opened ] + branches: + - main + - 'dspace-**' + +permissions: + pull-requests: write + +jobs: + automation: + runs-on: ubuntu-latest + steps: + # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards + # See https://github.com/toshimaru/auto-author-assign + - name: Assign PR to creator + uses: toshimaru/auto-author-assign@v2.1.0 diff --git a/.gitignore b/.gitignore index 7d065aca061..ce44f6b3fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.angular/cache +/.nx /__build__ /__server_build__ /node_modules diff --git a/Dockerfile b/Dockerfile index 8fac7495e1f..e395e4b90e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # This image will be published as dspace/dspace-angular # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details -FROM node:18-alpine +FROM docker.io/node:18-alpine # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 @@ -24,5 +24,5 @@ ENV NODE_OPTIONS="--max_old_space_size=4096" # Listen / accept connections from all IP addresses. # NOTE: At this time it is only possible to run Docker container in Production mode # if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485 -ENV NODE_ENV development +ENV NODE_ENV=development CMD yarn serve --host 0.0.0.0 diff --git a/Dockerfile.dist b/Dockerfile.dist index 2a6a66fc063..be72de4afc4 100644 --- a/Dockerfile.dist +++ b/Dockerfile.dist @@ -2,9 +2,9 @@ # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details # Test build: -# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist . +# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-8_x-dist . -FROM node:18-alpine as build +FROM docker.io/node:18-alpine AS build # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 @@ -26,6 +26,6 @@ COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json WORKDIR /app USER node -ENV NODE_ENV production +ENV NODE_ENV=production EXPOSE 4000 CMD pm2-runtime start dspace-ui.json --json diff --git a/README.md b/README.md index ebc24f8b918..fe2af85aa40 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v18.x` or `v20.x`, [npm](https://www.npmjs.com/) >= `v10.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v16.x` or `v18.x` and yarn == `v1.x` +- Ensure you're running node `v18.x` or `v20.x` and yarn == `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. diff --git a/angular.json b/angular.json index ea6f12f8226..89877a3d7e3 100644 --- a/angular.json +++ b/angular.json @@ -30,7 +30,6 @@ "lodash", "jwt-decode", "uuid", - "webfontloader", "zone.js" ], "outputPath": "dist/browser", @@ -114,22 +113,22 @@ "serve": { "builder": "@angular-builders/custom-webpack:dev-server", "options": { - "browserTarget": "dspace-angular:build", + "buildTarget": "dspace-angular:build", "port": 4000 }, "configurations": { "development": { - "browserTarget": "dspace-angular:build:development" + "buildTarget": "dspace-angular:build:development" }, "production": { - "browserTarget": "dspace-angular:build:production" + "buildTarget": "dspace-angular:build:production" } } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { - "browserTarget": "dspace-angular:build" + "buildTarget": "dspace-angular:build" } }, "test": { @@ -222,23 +221,23 @@ } }, "serve-ssr": { - "builder": "@nguniversal/builders:ssr-dev-server", + "builder": "@angular-devkit/build-angular:ssr-dev-server", "options": { - "browserTarget": "dspace-angular:build", + "buildTarget": "dspace-angular:build", "serverTarget": "dspace-angular:server", "port": 4000 }, "configurations": { "production": { - "browserTarget": "dspace-angular:build:production", + "buildTarget": "dspace-angular:build:production", "serverTarget": "dspace-angular:server:production" } } }, "prerender": { - "builder": "@nguniversal/builders:prerender", + "builder": "@angular-devkit/build-angular:prerender", "options": { - "browserTarget": "dspace-angular:build:production", + "buildTarget": "dspace-angular:build:production", "serverTarget": "dspace-angular:server:production", "routes": [ "/" @@ -271,6 +270,8 @@ "options": { "lintFilePatterns": [ "src/**/*.ts", + "cypress/**/*.ts", + "lint/**/*.ts", "src/**/*.html", "src/**/*.json5" ] diff --git a/config/config.example.yml b/config/config.example.yml index 42c61235b9b..8ea58b96e40 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,7 +1,7 @@ # NOTE: will log all redux actions and transfers in console debug: false -# Angular Universal server settings +# Angular User Inteface settings # NOTE: these settings define where Node.js will start your UI application. Therefore, these # "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar) ui: @@ -17,15 +17,64 @@ ui: # Trust X-FORWARDED-* headers from proxies (default = true) useProxies: true +# Angular Server Side Rendering (SSR) settings +ssr: + # Whether to tell Angular to inline "critical" styles into the server-side rendered HTML. + # Determining which styles are critical is a relatively expensive operation; this option is + # disabled (false) by default to boost server performance at the expense of loading smoothness. + inlineCriticalCss: false + # Patterns to be run as regexes against the path of the page to check if SSR is allowed. + # If the path match any of the regexes it will be served directly in CSR. + # By default, excludes community and collection browse, global browse, global search, community list, statistics and various administrative tools. + excludePathPatterns: + - pattern: "^/communities/[a-f0-9-]{36}/browse(/.*)?$" + flag: "i" + - pattern: "^/collections/[a-f0-9-]{36}/browse(/.*)?$" + flag: "i" + - pattern: "^/browse/" + - pattern: "^/search$" + - pattern: "^/community-list$" + - pattern: "^/admin/" + - pattern: "^/processes/?" + - pattern: "^/notifications/" + - pattern: "^/statistics/?" + - pattern: "^/access-control/" + - pattern: "^/health$" + + # Whether to enable rendering of Search component on SSR. + # If set to true the component will be included in the HTML returned from the server side rendering. + # If set to false the component will not be included in the HTML returned from the server side rendering. + enableSearchComponent: false + # Whether to enable rendering of Browse component on SSR. + # If set to true the component will be included in the HTML returned from the server side rendering. + # If set to false the component will not be included in the HTML returned from the server side rendering. + enableBrowseComponent: false + # Enable state transfer from the server-side application to the client-side application. + # Defaults to true. + # Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it. + # Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and + # ensure that users always use the most up-to-date state. + transferState: true + # When a different REST base URL is used for the server-side application, the generated state contains references to + # REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs. + # Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues. + replaceRestUrl: true + # Enable request performance profiling data collection and printing the results in the server console. + # Defaults to false. Enabling in production is NOT recommended + #enablePerformanceProfiler: false + # The REST API server settings # NOTE: these settings define which (publicly available) REST API to use. They are usually # 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. rest: ssl: true - host: demo.dspace.org + host: sandbox.dspace.org port: 443 # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: /server + # Provide a different REST url to be used during SSR execution. It must contain the whole url including protocol, server port and + # server namespace (uncomment to use it). + #ssrBaseUrl: http://localhost:8080/server # Caching settings cache: @@ -75,7 +124,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -131,12 +180,16 @@ submission: # NOTE: after how many time (milliseconds) submission is saved automatically # eg. timer: 5 * (1000 * 60); // 5 minutes timer: 0 + # Always show the duplicate detection section if enabled, even if there are no potential duplicates detected + # (a message will be displayed to indicate no matches were found) + duplicateDetection: + alwaysShowSection: false icons: metadata: # NOTE: example of configuration # # NOTE: metadata name # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used # style: fas fa-user - name: dc.author style: fas fa-user @@ -147,18 +200,40 @@ submission: confidence: # NOTE: example of configuration # # NOTE: confidence value - # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used - # style: fa-user + # - value: 600 + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used + # style: text-success + # icon: fa-circle-check + # # NOTE: the class configured in property style is used by default, the icon property could be used in component + # configured to use a 'icon mode' display (mainly in edit-item page) - value: 600 style: text-success + icon: fa-circle-check - value: 500 style: text-info + icon: fa-gear - value: 400 style: text-warning + icon: fa-circle-question + - value: 300 + style: text-muted + icon: fa-thumbs-down + - value: 200 + style: text-muted + icon: fa-circle-exclamation + - value: 100 + style: text-muted + icon: fa-circle-stop + - value: 0 + style: text-muted + icon: fa-ban + - value: -1 + style: text-muted + icon: fa-circle-xmark # default configuration - value: default style: text-muted + icon: fa-circle-xmark # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en @@ -169,6 +244,12 @@ languages: - code: en label: English active: true + - code: ar + label: العربية + active: true + - code: bn + label: বাংলা + active: true - code: ca label: Català active: true @@ -178,24 +259,36 @@ languages: - code: de label: Deutsch active: true + - code: el + label: Ελληνικά + active: true - code: es label: Español active: true + - code: fi + label: Suomi + active: true - code: fr label: Français active: true - code: gd label: Gàidhlig active: true + - code: hi + label: हिंदी + active: true + - code: hu + label: Magyar + active: true - code: it label: Italiano active: true + - code: kk + label: Қазақ + active: true - code: lv label: Latviešu active: true - - code: hu - label: Magyar - active: true - code: nl label: Nederlands active: true @@ -211,8 +304,8 @@ languages: - code: sr-lat label: Srpski (lat) active: true - - code: fi - label: Suomi + - code: sr-cyr + label: Српски active: true - code: sv label: Svenska @@ -220,24 +313,12 @@ languages: - code: tr label: Türkçe active: true - - code: vi - label: Tiếng Việt - 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 + - code: vi + label: Tiếng Việt + active: true # Browse-By Pages @@ -269,6 +350,8 @@ homePage: # No. of communities to list per page on the home page # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 pageSize: 5 + # Enable or disable the Discover filters on the homepage + showDiscoverFilters: false # Item Config item: @@ -282,8 +365,25 @@ item: # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: 5 +# Community Page Config +community: + # Default tab to be shown when browsing a Community. Valid values are: comcols, search, or browse_ + # must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject + # When the default tab is not the 'search' tab, the search tab is moved to the last position + defaultBrowseTab: search + # Search tab config + searchSection: + showSidebar: true + # Collection Page Config collection: + # Default tab to be shown when browsing a Collection. Valid values are: search, or browse_ + # must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject + # When the default tab is not the 'search' tab, the search tab is moved to the last position + defaultBrowseTab: search + # Search tab config + searchSection: + showSidebar: true edit: undoTimeout: 10000 # 10 seconds @@ -360,10 +460,11 @@ mediaViewer: # Whether the end user agreement is required before users use the repository. # If enabled, the user will be required to accept the agreement before they can use the repository. -# And whether the privacy statement should exist or not. +# And whether the privacy statement/COAR notify support page should exist or not. info: enableEndUserAgreement: true enablePrivacyStatement: true + enableCOARNotifySupport: true # Whether to enable Markdown (https://commonmark.org/) and MathJax (https://www.mathjax.org/) # display in supported metadata fields. By default, only dc.description.abstract is supported. @@ -379,7 +480,100 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' + +# Example of fallback collection for suggestions import +# suggestion: + # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af + # source: "openaire" + + +# Search settings +search: + # Settings to enable/disable or configure advanced search filters. + advancedFilters: + enabled: false + # List of filters to enable in "Advanced Search" dropdown + filter: [ 'title', 'author', 'subject', 'entityType' ] + # + # Number used to render n UI elements called loading skeletons that act as placeholders. + # These elements indicate that some content will be loaded in their stead. + # Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count. + # e.g. If we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved. + defaultFiltersCount: 5 + + +# Notify metrics +# Configuration for Notify Admin Dashboard for metrics visualization +notifyMetrics: + # Configuration for received messages +- title: 'admin-notify-dashboard.received-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.incoming.accepted' + config: 'NOTIFY.incoming.accepted' + description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.incoming.processed' + config: 'NOTIFY.incoming.processed' + description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.failure' + config: 'NOTIFY.incoming.failure' + description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted' + config: 'NOTIFY.incoming.untrusted' + description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems' + textColor: '#fff' + config: 'NOTIFY.incoming.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description' +# Configuration for outgoing messages +- title: 'admin-notify-dashboard.generated-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued' + config: 'NOTIFY.outgoing.queued' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description' + - color: '#FDEEBB' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry' + config: 'NOTIFY.outgoing.queued_for_retry' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.outgoing.failure' + config: 'NOTIFY.outgoing.failure' + description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems' + textColor: '#fff' + config: 'NOTIFY.outgoing.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered' + config: 'NOTIFY.outgoing.delivered' + description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' + + +# Live Region configuration +# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms: +# Live regions are perceivable regions of a web page that are typically updated as a +# result of an external event when user focus may be elsewhere. +# +# The DSpace live region is a component present at the bottom of all pages that is invisible by default, but is useful +# for screen readers. Any message pushed to the live region will be announced by the screen reader. These messages +# usually contain information about changes on the page that might not be in focus. +liveRegion: + # The duration after which messages disappear from the live region in milliseconds + messageTimeOutDurationMs: 30000 + # The visibility of the live region. Setting this to true is only useful for debugging purposes. + isVisible: false + +# Configuration for storing accessibility settings, used by the AccessibilitySettingsService +accessibility: + # The duration in days after which the accessibility settings cookie expires + cookieExpirationDuration: 7 diff --git a/config/config.yml b/config/config.yml index a5337cdd0d4..109db60ca92 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,5 +1,5 @@ rest: - ssl: false - host: localhost - port: 8080 + ssl: true + host: sandbox.dspace.org + port: 443 nameSpace: /server diff --git a/cypress.config.ts b/cypress.config.ts index 91eeb9838b3..36d8120342a 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'cypress'; export default defineConfig({ + video: true, videosFolder: 'cypress/videos', screenshotsFolder: 'cypress/screenshots', fixturesFolder: 'cypress/fixtures', @@ -9,27 +10,33 @@ export default defineConfig({ openMode: 0, }, env: { - // Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts) - // May be overridden in our cypress.json config file using specified environment variables. + // Global DSpace environment variables used in all our Cypress e2e tests + // May be modified in this config, or overridden in a variety of ways. + // See Cypress environment variable docs: https://docs.cypress.io/guides/guides/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) // Admin account used for administrative tests DSPACE_TEST_ADMIN_USER: 'dspacedemo+admin@gmail.com', + DSPACE_TEST_ADMIN_USER_UUID: '335647b6-8a52-4ecb-a8c1-7ebabb199bda', DSPACE_TEST_ADMIN_PASSWORD: 'dspace', // Community/collection/publication used for view/edit tests 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_ENTITY_PUBLICATION: '6160810f-1e53-40db-81ef-f6621a727398', // Search term (should return results) used in search tests DSPACE_TEST_SEARCH_TERM: 'test', - // Collection used for submission tests + // Main Collection used for submission tests. Should be able to accept normal Item objects DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection', DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144', + // Collection used for Person entity submission tests. MUST be configured with EntityType=Person. + DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME: 'People', // Account used to test basic submission process DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', + // Administrator users group + DSPACE_ADMINISTRATOR_GROUP: 'e59f5659-bff9-451e-b28f-439e7bd467e4' }, e2e: { // Setup our plugins for e2e tests diff --git a/cypress/e2e/admin-add-new-modals.cy.ts b/cypress/e2e/admin-add-new-modals.cy.ts new file mode 100644 index 00000000000..332d44da138 --- /dev/null +++ b/cypress/e2e/admin-add-new-modals.cy.ts @@ -0,0 +1,54 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Add New Modals', () => { + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('Add new Community modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-new-title').should('be.visible'); + cy.get('#admin-menu-section-new-title').click(); + + cy.get('a[data-test="menu.section.new_community"]').click(); + + // Analyze for accessibility + testA11y('ds-create-community-parent-selector'); + }); + + it('Add new Collection modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-new-title').should('be.visible'); + cy.get('#admin-menu-section-new-title').click(); + + cy.get('a[data-test="menu.section.new_collection"]').click(); + + // Analyze for accessibility + testA11y('ds-create-collection-parent-selector'); + }); + + it('Add new Item modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-new-title').should('be.visible'); + cy.get('#admin-menu-section-new-title').click(); + + cy.get('a[data-test="menu.section.new_item"]').click(); + + // Analyze for accessibility + testA11y('ds-create-item-parent-selector'); + }); +}); diff --git a/cypress/e2e/admin-curation-tasks.cy.ts b/cypress/e2e/admin-curation-tasks.cy.ts new file mode 100644 index 00000000000..e66f0ccaad8 --- /dev/null +++ b/cypress/e2e/admin-curation-tasks.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Curation Tasks', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/curation-tasks'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-admin-curation-task').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-admin-curation-task'); + }); +}); diff --git a/cypress/e2e/admin-edit-modals.cy.ts b/cypress/e2e/admin-edit-modals.cy.ts new file mode 100644 index 00000000000..8ba524d5be1 --- /dev/null +++ b/cypress/e2e/admin-edit-modals.cy.ts @@ -0,0 +1,54 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Edit Modals', () => { + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('Edit Community modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-edit-title').should('be.visible'); + cy.get('#admin-menu-section-edit-title').click(); + + cy.get('a[data-test="menu.section.edit_community"]').click(); + + // Analyze for accessibility + testA11y('ds-edit-community-selector'); + }); + + it('Edit Collection modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-edit-title').should('be.visible'); + cy.get('#admin-menu-section-edit-title').click(); + + cy.get('a[data-test="menu.section.edit_collection"]').click(); + + // Analyze for accessibility + testA11y('ds-edit-collection-selector'); + }); + + it('Edit Item modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-edit-title').should('be.visible'); + cy.get('#admin-menu-section-edit-title').click(); + + cy.get('a[data-test="menu.section.edit_item"]').click(); + + // Analyze for accessibility + testA11y('ds-edit-item-selector'); + }); +}); diff --git a/cypress/e2e/admin-export-modals.cy.ts b/cypress/e2e/admin-export-modals.cy.ts new file mode 100644 index 00000000000..884db4ed33e --- /dev/null +++ b/cypress/e2e/admin-export-modals.cy.ts @@ -0,0 +1,39 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Export Modals', () => { + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('Export metadata modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-export-title').should('be.visible'); + cy.get('#admin-menu-section-export-title').click(); + + cy.get('a[data-test="menu.section.export_metadata"]').click(); + + // Analyze for accessibility + testA11y('ds-export-metadata-selector'); + }); + + it('Export batch modal should pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); + cy.get('#sidebar-collapse-toggle').click(); + + // Click on entry of menu + cy.get('#admin-menu-section-export-title').should('be.visible'); + cy.get('#admin-menu-section-export-title').click(); + + cy.get('a[data-test="menu.section.export_batch"]').click(); + + // Analyze for accessibility + testA11y('ds-export-batch-selector'); + }); +}); diff --git a/cypress/e2e/admin-notifications-publication-claim-page.cy.ts b/cypress/e2e/admin-notifications-publication-claim-page.cy.ts new file mode 100644 index 00000000000..877a0542e25 --- /dev/null +++ b/cypress/e2e/admin-notifications-publication-claim-page.cy.ts @@ -0,0 +1,17 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Notifications Publication Claim Page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/notifications/publication-claim'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + + //Page must first be visible + cy.get('ds-admin-notifications-publication-claim-page').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-admin-notifications-publication-claim-page'); + }); +}); diff --git a/cypress/e2e/admin-search-page.cy.ts b/cypress/e2e/admin-search-page.cy.ts new file mode 100644 index 00000000000..4fbf8939fe4 --- /dev/null +++ b/cypress/e2e/admin-search-page.cy.ts @@ -0,0 +1,21 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Search Page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/search'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + //Page must first be visible + cy.get('ds-admin-search-page').should('be.visible'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); + // Analyze for accessibility issues + testA11y('ds-admin-search-page'); + }); +}); diff --git a/cypress/e2e/admin-sidebar.cy.ts b/cypress/e2e/admin-sidebar.cy.ts new file mode 100644 index 00000000000..be1c9d4ef27 --- /dev/null +++ b/cypress/e2e/admin-sidebar.cy.ts @@ -0,0 +1,28 @@ +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +describe('Admin Sidebar', () => { + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should be pinnable and pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').click(); + + // Click on every expandable section to open all menus + cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true }); + + // Analyze for accessibility + testA11y('ds-admin-sidebar', + { + rules: { + // Currently all expandable sections have nested interactive elements + // See https://github.com/DSpace/dspace-angular/issues/2178 + 'nested-interactive': { enabled: false }, + }, + } as Options); + }); +}); diff --git a/cypress/e2e/admin-workflow-page.cy.ts b/cypress/e2e/admin-workflow-page.cy.ts new file mode 100644 index 00000000000..c3c235e346d --- /dev/null +++ b/cypress/e2e/admin-workflow-page.cy.ts @@ -0,0 +1,21 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Workflow Page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/workflow'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-admin-workflow-page').should('be.visible'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); + // Analyze for accessibility issues + testA11y('ds-admin-workflow-page'); + }); +}); diff --git a/cypress/e2e/batch-import-page.cy.ts b/cypress/e2e/batch-import-page.cy.ts new file mode 100644 index 00000000000..871b8644ce1 --- /dev/null +++ b/cypress/e2e/batch-import-page.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Batch Import Page', () => { + beforeEach(() => { + // Must login as an Admin to see processes + cy.visit('/admin/batch-import'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Batch import form must first be visible + cy.get('ds-batch-import-page').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-batch-import-page'); + }); +}); diff --git a/cypress/e2e/bitstreams-format.cy.ts b/cypress/e2e/bitstreams-format.cy.ts new file mode 100644 index 00000000000..f113d45ebce --- /dev/null +++ b/cypress/e2e/bitstreams-format.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Bitstreams Formats', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/registries/bitstream-formats'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-bitstream-formats').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-bitstream-formats'); + }); +}); diff --git a/cypress/e2e/breadcrumbs.cy.ts b/cypress/e2e/breadcrumbs.cy.ts index ea6acdafcde..f660f47a540 100644 --- a/cypress/e2e/breadcrumbs.cy.ts +++ b/cypress/e2e/breadcrumbs.cy.ts @@ -1,15 +1,14 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Breadcrumbs', () => { - it('should pass accessibility tests', () => { - // Visit an Item, as those have more breadcrumbs - cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); + it('should pass accessibility tests', () => { + // Visit an Item, as those have more breadcrumbs + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); - // Wait for breadcrumbs to be visible - cy.get('ds-breadcrumbs').should('be.visible'); + // Wait for breadcrumbs to be visible + cy.get('ds-breadcrumbs').should('be.visible'); - // Analyze for accessibility - testA11y('ds-breadcrumbs'); - }); + // Analyze for accessibility + testA11y('ds-breadcrumbs'); + }); }); diff --git a/cypress/e2e/browse-by-author.cy.ts b/cypress/e2e/browse-by-author.cy.ts index 07c20ad7c91..3e914a2f8c0 100644 --- a/cypress/e2e/browse-by-author.cy.ts +++ b/cypress/e2e/browse-by-author.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Author', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/author'); + it('should pass accessibility tests', () => { + cy.visit('/browse/author'); - // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-metadata').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-metadata'); + }); }); diff --git a/cypress/e2e/browse-by-dateissued.cy.ts b/cypress/e2e/browse-by-dateissued.cy.ts index 4d22420227c..5fe05433153 100644 --- a/cypress/e2e/browse-by-dateissued.cy.ts +++ b/cypress/e2e/browse-by-dateissued.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Date Issued', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/dateissued'); + it('should pass accessibility tests', () => { + cy.visit('/browse/dateissued'); - // Wait for to be visible - cy.get('ds-browse-by-date-page').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-date').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-date-page'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-date'); + }); }); diff --git a/cypress/e2e/browse-by-subject.cy.ts b/cypress/e2e/browse-by-subject.cy.ts index 89b791f03c4..0937a2542bb 100644 --- a/cypress/e2e/browse-by-subject.cy.ts +++ b/cypress/e2e/browse-by-subject.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Subject', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/subject'); + it('should pass accessibility tests', () => { + cy.visit('/browse/subject'); - // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-metadata').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-metadata'); + }); }); diff --git a/cypress/e2e/browse-by-title.cy.ts b/cypress/e2e/browse-by-title.cy.ts index e4e027586a8..71a7356ce32 100644 --- a/cypress/e2e/browse-by-title.cy.ts +++ b/cypress/e2e/browse-by-title.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Browse By Title', () => { - it('should pass accessibility tests', () => { - cy.visit('/browse/title'); + it('should pass accessibility tests', () => { + cy.visit('/browse/title'); - // Wait for to be visible - cy.get('ds-browse-by-title-page').should('be.visible'); + // Wait for to be visible + cy.get('ds-browse-by-title').should('be.visible'); - // Analyze for accessibility - testA11y('ds-browse-by-title-page'); - }); + // Analyze for accessibility + testA11y('ds-browse-by-title'); + }); }); diff --git a/cypress/e2e/bulk-access.cy.ts b/cypress/e2e/bulk-access.cy.ts new file mode 100644 index 00000000000..87033e13e4f --- /dev/null +++ b/cypress/e2e/bulk-access.cy.ts @@ -0,0 +1,31 @@ +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +describe('Bulk Access', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/bulk-access'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-bulk-access').should('be.visible'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); + // Analyze for accessibility issues + testA11y('ds-bulk-access', { + rules: { + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + // Card titles fail this test currently + 'heading-order': { enabled: false }, + }, + } as Options); + }); +}); diff --git a/cypress/e2e/collection-create.cy.ts b/cypress/e2e/collection-create.cy.ts new file mode 100644 index 00000000000..29f7dd5cacb --- /dev/null +++ b/cypress/e2e/collection-create.cy.ts @@ -0,0 +1,13 @@ +beforeEach(() => { + cy.visit('/collections/create?parent='.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +it('should show loading component while saving', () => { + const title = 'Test Collection Title'; + cy.get('#title').type(title); + + cy.get('button[type="submit"]').click(); + + cy.get('ds-loading').should('be.visible'); +}); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts new file mode 100644 index 00000000000..e1ba1c5eed8 --- /dev/null +++ b/cypress/e2e/collection-edit.cy.ts @@ -0,0 +1,128 @@ +import { testA11y } from 'cypress/support/utils'; + +const COLLECTION_EDIT_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Collection Page + cy.visit(COLLECTION_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Collection > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-collection').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-collection'); + }); +}); + +describe('Edit Collection > Assign Roles tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); + + // tag must be loaded + cy.get('ds-collection-roles').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-roles'); + }); +}); + +describe('Edit Collection > Content Source tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="source"]').click(); + + // tag must be loaded + cy.get('ds-collection-source').should('be.visible'); + + // Check the external source checkbox (to display all fields on the page) + cy.get('#externalSourceCheck').check(); + + // Wait for the source controls to appear + // cy.get('ds-collection-source-controls').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-collection-source'); + }); +}); + +describe('Edit Collection > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-collection-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-curate'); + }); +}); + +describe('Edit Collection > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-collection-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-access-control'); + }); +}); + +describe('Edit Collection > Authorizations tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); + + // tag must be loaded + cy.get('ds-collection-authorizations').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-authorizations'); + }); +}); + +describe('Edit Collection > Item Mapper tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); + + // tag must be loaded + cy.get('ds-collection-item-mapper').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-collection-item-mapper'); + + // Click on the "Map new Items" tab + cy.get('li[data-test="mapTab"] a').click(); + + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); + + // Analyze entire page (again) for accessibility issues + testA11y('ds-collection-item-mapper'); + }); +}); + + +describe('Edit Collection > Delete page', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); + + // tag must be loaded + cy.get('ds-delete-collection').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-delete-collection'); + }); +}); diff --git a/cypress/e2e/collection-page.cy.ts b/cypress/e2e/collection-page.cy.ts index a034b4361d6..d12536d332a 100644 --- a/cypress/e2e/collection-page.cy.ts +++ b/cypress/e2e/collection-page.cy.ts @@ -1,15 +1,14 @@ -import { TEST_COLLECTION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Page', () => { - it('should pass accessibility tests', () => { - cy.visit('/collections/'.concat(TEST_COLLECTION)); + it('should pass accessibility tests', () => { + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); - // tag must be loaded - cy.get('ds-collection-page').should('be.visible'); + // tag must be loaded + cy.get('ds-collection-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-collection-page'); - }); + // Analyze for accessibility issues + testA11y('ds-collection-page'); + }); }); diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts index 6df4e9a4542..3e5a465e398 100644 --- a/cypress/e2e/collection-statistics.cy.ts +++ b/cypress/e2e/collection-statistics.cy.ts @@ -1,37 +1,37 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Statistics Page', () => { - const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION); - - it('should load if you click on "Statistics" from a Collection page', () => { - cy.visit('/collections/'.concat(TEST_COLLECTION)); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); - }); - - it('should contain a "Total visits" section', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - cy.get('table[data-test="TotalVisits"]').should('be.visible'); - }); - - it('should contain a "Total visits per month" section', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_COLLECTION).concat('_TotalVisitsPerMonth')).should('exist'); - }); - - it('should pass accessibility tests', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - - // tag must be loaded - cy.get('ds-collection-statistics-page').should('be.visible'); - - // Verify / wait until "Total Visits" table's label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); - - // Analyze for accessibility issues - testA11y('ds-collection-statistics-page'); - }); + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')); + + it('should load if you click on "Statistics" from a Collection page', () => { + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + // Check just for existence because this table is empty in CI environment as it's historical data + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-collection-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + + // Analyze for accessibility issues + testA11y('ds-collection-statistics-page'); + }); }); diff --git a/cypress/e2e/community-create.cy.ts b/cypress/e2e/community-create.cy.ts new file mode 100644 index 00000000000..96bc003ba2a --- /dev/null +++ b/cypress/e2e/community-create.cy.ts @@ -0,0 +1,13 @@ +beforeEach(() => { + cy.visit('/communities/create'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +it('should show loading component while saving', () => { + const title = 'Test Community Title'; + cy.get('#title').type(title); + + cy.get('button[type="submit"]').click(); + + cy.get('ds-loading').should('be.visible'); +}); diff --git a/cypress/e2e/community-edit.cy.ts b/cypress/e2e/community-edit.cy.ts new file mode 100644 index 00000000000..77e260feec0 --- /dev/null +++ b/cypress/e2e/community-edit.cy.ts @@ -0,0 +1,86 @@ +import { testA11y } from 'cypress/support/utils'; + +const COMMUNITY_EDIT_PAGE = '/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Community Page + cy.visit(COMMUNITY_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Community > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-community').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-community'); + }); +}); + +describe('Edit Community > Assign Roles tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); + + // tag must be loaded + cy.get('ds-community-roles').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-roles'); + }); +}); + +describe('Edit Community > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-community-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-curate'); + }); +}); + +describe('Edit Community > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-community-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-access-control'); + }); +}); + +describe('Edit Community > Authorizations tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); + + // tag must be loaded + cy.get('ds-community-authorizations').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-authorizations'); + }); +}); + +describe('Edit Community > Delete page', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); + + // tag must be loaded + cy.get('ds-delete-community').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-delete-community'); + }); +}); diff --git a/cypress/e2e/community-list.cy.ts b/cypress/e2e/community-list.cy.ts index c371f6ceae7..9b9c87b112d 100644 --- a/cypress/e2e/community-list.cy.ts +++ b/cypress/e2e/community-list.cy.ts @@ -2,16 +2,16 @@ import { testA11y } from 'cypress/support/utils'; describe('Community List Page', () => { - it('should pass accessibility tests', () => { - cy.visit('/community-list'); + it('should pass accessibility tests', () => { + cy.visit('/community-list'); - // tag must be loaded - cy.get('ds-community-list-page').should('be.visible'); + // tag must be loaded + cy.get('ds-community-list-page').should('be.visible'); - // Open every expand button on page, so that we can scan sub-elements as well - cy.get('[data-test="expand-button"]').click({ multiple: true }); + // Open every expand button on page, so that we can scan sub-elements as well + cy.get('[data-test="expand-button"]').click({ multiple: true }); - // Analyze for accessibility issues - testA11y('ds-community-list-page'); - }); + // Analyze for accessibility issues + testA11y('ds-community-list-page'); + }); }); diff --git a/cypress/e2e/community-page.cy.ts b/cypress/e2e/community-page.cy.ts index 6c628e21ce1..5a4441dbae8 100644 --- a/cypress/e2e/community-page.cy.ts +++ b/cypress/e2e/community-page.cy.ts @@ -1,15 +1,14 @@ -import { TEST_COMMUNITY } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Page', () => { - it('should pass accessibility tests', () => { - cy.visit('/communities/'.concat(TEST_COMMUNITY)); + it('should pass accessibility tests', () => { + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); - // tag must be loaded - cy.get('ds-community-page').should('be.visible'); + // tag must be loaded + cy.get('ds-community-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-community-page',); - }); + // Analyze for accessibility issues + testA11y('ds-community-page'); + }); }); diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts index 710450e7972..00e23a90b37 100644 --- a/cypress/e2e/community-statistics.cy.ts +++ b/cypress/e2e/community-statistics.cy.ts @@ -1,37 +1,37 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Statistics Page', () => { - const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY); - - it('should load if you click on "Statistics" from a Community page', () => { - cy.visit('/communities/'.concat(TEST_COMMUNITY)); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); - }); - - it('should contain a "Total visits" section', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - cy.get('table[data-test="TotalVisits"]').should('be.visible'); - }); - - it('should contain a "Total visits per month" section', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_COMMUNITY).concat('_TotalVisitsPerMonth')).should('exist'); - }); - - it('should pass accessibility tests', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - - // tag must be loaded - cy.get('ds-community-statistics-page').should('be.visible'); - - // Verify / wait until "Total Visits" table's label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); - - // Analyze for accessibility issues - testA11y('ds-community-statistics-page'); - }); + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')); + + it('should load if you click on "Statistics" from a Community page', () => { + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + // Check just for existence because this table is empty in CI environment as it's historical data + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-community-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + + // Analyze for accessibility issues + testA11y('ds-community-statistics-page'); + }); }); diff --git a/cypress/e2e/create-eperson.cy.ts b/cypress/e2e/create-eperson.cy.ts new file mode 100644 index 00000000000..d23986ba29d --- /dev/null +++ b/cypress/e2e/create-eperson.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Create Eperson', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/epeople/create'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Form must first be visible + cy.get('ds-eperson-form').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-eperson-form'); + }); +}); diff --git a/cypress/e2e/create-group.cy.ts b/cypress/e2e/create-group.cy.ts new file mode 100644 index 00000000000..135c041a8d5 --- /dev/null +++ b/cypress/e2e/create-group.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Create Group', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/groups/create'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Form must first be visible + cy.get('ds-group-form').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-group-form'); + }); +}); diff --git a/cypress/e2e/edit-eperson.cy.ts b/cypress/e2e/edit-eperson.cy.ts new file mode 100644 index 00000000000..166c913b8c8 --- /dev/null +++ b/cypress/e2e/edit-eperson.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Edit Eperson', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/epeople/'.concat(Cypress.env('DSPACE_TEST_ADMIN_USER_UUID')).concat('/edit')); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Form must first be visible + cy.get('ds-eperson-form').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-eperson-form'); + }); +}); diff --git a/cypress/e2e/edit-group.cy.ts b/cypress/e2e/edit-group.cy.ts new file mode 100644 index 00000000000..e43ede978ad --- /dev/null +++ b/cypress/e2e/edit-group.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Edit Group', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/groups/'.concat(Cypress.env('DSPACE_ADMINISTRATOR_GROUP')).concat('/edit')); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Form must first be visible + cy.get('ds-group-form').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-group-form'); + }); +}); diff --git a/cypress/e2e/end-user-agreement.cy.ts b/cypress/e2e/end-user-agreement.cy.ts new file mode 100644 index 00000000000..989d21ce60f --- /dev/null +++ b/cypress/e2e/end-user-agreement.cy.ts @@ -0,0 +1,13 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('End User Agreement', () => { + it('should pass accessibility tests', () => { + cy.visit('/info/end-user-agreement'); + + // Page must first be visible + cy.get('ds-end-user-agreement').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-end-user-agreement'); + }); +}); diff --git a/cypress/e2e/epeople-registry.cy.ts b/cypress/e2e/epeople-registry.cy.ts new file mode 100644 index 00000000000..a6192f13d95 --- /dev/null +++ b/cypress/e2e/epeople-registry.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Epeople registry', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/epeople'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Epeople registry page must first be visible + cy.get('ds-epeople-registry').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-epeople-registry'); + }); +}); diff --git a/cypress/e2e/feedback.cy.ts b/cypress/e2e/feedback.cy.ts new file mode 100644 index 00000000000..75fe1097c63 --- /dev/null +++ b/cypress/e2e/feedback.cy.ts @@ -0,0 +1,13 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Feedback', () => { + it('should pass accessibility tests', () => { + cy.visit('/info/feedback'); + + // Page must first be visible + cy.get('ds-feedback').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-feedback'); + }); +}); diff --git a/cypress/e2e/footer.cy.ts b/cypress/e2e/footer.cy.ts index 656e9d47012..4ee1d6669ae 100644 --- a/cypress/e2e/footer.cy.ts +++ b/cypress/e2e/footer.cy.ts @@ -1,13 +1,13 @@ import { testA11y } from 'cypress/support/utils'; describe('Footer', () => { - it('should pass accessibility tests', () => { - cy.visit('/'); + it('should pass accessibility tests', () => { + cy.visit('/'); - // Footer must first be visible - cy.get('ds-footer').should('be.visible'); + // Footer must first be visible + cy.get('ds-footer').should('be.visible'); - // Analyze for accessibility - testA11y('ds-footer'); - }); + // Analyze for accessibility + testA11y('ds-footer'); + }); }); diff --git a/cypress/e2e/groups-registry.cy.ts b/cypress/e2e/groups-registry.cy.ts new file mode 100644 index 00000000000..5c0099c2f1f --- /dev/null +++ b/cypress/e2e/groups-registry.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Groups registry', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/access-control/groups'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Epeople registry page must first be visible + cy.get('ds-groups-registry').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-groups-registry'); + }); +}); diff --git a/cypress/e2e/header.cy.ts b/cypress/e2e/header.cy.ts index 1a9b841eb7d..aa65aee570e 100644 --- a/cypress/e2e/header.cy.ts +++ b/cypress/e2e/header.cy.ts @@ -1,18 +1,38 @@ import { testA11y } from 'cypress/support/utils'; describe('Header', () => { - it('should pass accessibility tests', () => { - cy.visit('/'); - - // Header must first be visible - cy.get('ds-header').should('be.visible'); - - // Analyze for accessibility - testA11y({ - include: ['ds-header'], - exclude: [ - ['#search-navbar-container'] // search in navbar has duplicative ID. Will be fixed in #1174 - ], - }); - }); + it('should pass accessibility tests', () => { + cy.visit('/'); + + // Header must first be visible + cy.get('ds-header').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-header'); + }); + + it('should allow for changing language to German (for example)', () => { + cy.visit('/'); + + // Click the language switcher (globe) in header + cy.get('button[data-test="lang-switch"]').click(); + // Click on the "Deusch" language in dropdown + cy.get('#language-menu-list div[role="option"]').contains('Deutsch').click(); + + // HTML "lang" attribute should switch to "de" + cy.get('html').invoke('attr', 'lang').should('eq', 'de'); + + // Login menu should now be in German + cy.get('[data-test="login-menu"]').contains('Anmelden'); + + // Change back to English from language switcher + cy.get('button[data-test="lang-switch"]').click(); + cy.get('#language-menu-list div[role="option"]').contains('English').click(); + + // HTML "lang" attribute should switch to "en" + cy.get('html').invoke('attr', 'lang').should('eq', 'en'); + + // Login menu should now be in English + cy.get('[data-test="login-menu"]').contains('Log In'); + }); }); diff --git a/cypress/e2e/health-page.cy.ts b/cypress/e2e/health-page.cy.ts new file mode 100644 index 00000000000..c702fa72d79 --- /dev/null +++ b/cypress/e2e/health-page.cy.ts @@ -0,0 +1,62 @@ +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + + +beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/health'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Health Page > Status Tab', () => { + it('should pass accessibility tests', () => { + cy.intercept('GET', '/server/actuator/health').as('status'); + cy.wait('@status'); + + cy.get('a[data-test="health-page.status-tab"]').click(); + // Page must first be visible + cy.get('ds-health-page').should('be.visible'); + cy.get('ds-health-panel').should('be.visible'); + + // wait for all the ds-health-info-component components to be rendered + cy.get('div[role="tabpanel"]').each(($panel: HTMLDivElement) => { + cy.wrap($panel).find('ds-health-component').should('be.visible'); + }); + // Analyze for accessibility issues + testA11y('ds-health-page', { + rules: { + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + }, + } as Options); + }); +}); + +describe('Health Page > Info Tab', () => { + it('should pass accessibility tests', () => { + cy.intercept('GET', '/server/actuator/info').as('info'); + cy.wait('@info'); + + cy.get('a[data-test="health-page.info-tab"]').click(); + // Page must first be visible + cy.get('ds-health-page').should('be.visible'); + cy.get('ds-health-info').should('be.visible'); + + // wait for all the ds-health-info-component components to be rendered + cy.get('div[role="tabpanel"]').each(($panel: HTMLDivElement) => { + cy.wrap($panel).find('ds-health-info-component').should('be.visible'); + }); + + // Analyze for accessibility issues + testA11y('ds-health-info', { + rules: { + // All panels are accordions & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + }, + } as Options); + }); +}); diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts index 2a1ab9785ab..f9642c0c831 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -1,31 +1,32 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; -import { testA11y } from 'cypress/support/utils'; import '../support/commands'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; + describe('Site Statistics Page', () => { - it('should load if you click on "Statistics" from homepage', () => { - cy.visit('/'); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', '/statistics'); - }); + it('should load if you click on "Statistics" from homepage', () => { + cy.visit('/'); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', '/statistics'); + }); - it('should pass accessibility tests', () => { - // generate 2 view events on an Item's page - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + it('should pass accessibility tests', () => { + // generate 2 view events on an Item's page + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); - cy.visit('/statistics'); + cy.visit('/statistics'); - // tag must be visable - cy.get('ds-site-statistics-page').should('be.visible'); + // tag must be visable + cy.get('ds-site-statistics-page').should('be.visible'); - // Verify / wait until "Total Visits" table's *last* label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT); - // Wait an extra 500ms, just so all entries in Total Visits have loaded. - cy.wait(500); + // Verify / wait until "Total Visits" table's *last* label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT); + // Wait an extra 500ms, just so all entries in Total Visits have loaded. + cy.wait(500); - // Analyze for accessibility issues - testA11y('ds-site-statistics-page'); - }); + // Analyze for accessibility issues + testA11y('ds-site-statistics-page'); + }); }); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts new file mode 100644 index 00000000000..ad5d8ea0930 --- /dev/null +++ b/cypress/e2e/item-edit.cy.ts @@ -0,0 +1,180 @@ +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Item Page + cy.visit(ITEM_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Item > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + cy.get('a[data-test="metadata"]').should('be.visible'); + cy.get('a[data-test="metadata"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="metadata"]').should('be.visible'); + cy.get('a[data-test="metadata"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-edit-item-page').should('be.visible'); + + // wait for all the ds-dso-edit-metadata-value components to be rendered + cy.get('ds-dso-edit-metadata-value div[role="row"]').each(($row: HTMLDivElement) => { + cy.wrap($row).find('div[role="cell"]').should('be.visible'); + }); + + // Analyze for accessibility issues + testA11y('ds-edit-item-page'); + }); +}); + +describe('Edit Item > Status tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="status"]').should('be.visible'); + cy.get('a[data-test="status"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="status"]').should('be.visible'); + cy.get('a[data-test="status"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-status').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-status'); + }); +}); + +describe('Edit Item > Bitstreams tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="bitstreams"]').should('be.visible'); + cy.get('a[data-test="bitstreams"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="bitstreams"]').should('be.visible'); + cy.get('a[data-test="bitstreams"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-bitstreams').should('be.visible'); + + // Table of item bitstreams must also be loaded + cy.get('div.item-bitstreams').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-bitstreams', + { + rules: { + // Currently Bitstreams page loads a pagination component per Bundle + // and they all use the same 'id="p-dad"'. + 'duplicate-id': { enabled: false }, + }, + } as Options, + ); + }); +}); + +describe('Edit Item > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').should('be.visible'); + cy.get('a[data-test="curate"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="curate"]').should('be.visible'); + cy.get('a[data-test="curate"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-curate'); + }); +}); + +describe('Edit Item > Relationships tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="relationships"]').should('be.visible'); + cy.get('a[data-test="relationships"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="relationships"]').should('be.visible'); + cy.get('a[data-test="relationships"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-relationships').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-relationships'); + }); +}); + +describe('Edit Item > Version History tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="versionhistory"]').should('be.visible'); + cy.get('a[data-test="versionhistory"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="versionhistory"]').should('be.visible'); + cy.get('a[data-test="versionhistory"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-version-history').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-version-history'); + }); +}); + +describe('Edit Item > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').should('be.visible'); + cy.get('a[data-test="access-control"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="access-control"]').should('be.visible'); + cy.get('a[data-test="access-control"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-access-control'); + }); +}); + +describe('Edit Item > Collection Mapper tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').should('be.visible'); + cy.get('a[data-test="mapper"]').click(); + + // Our selected tab should be both visible & active + cy.get('a[data-test="mapper"]').should('be.visible'); + cy.get('a[data-test="mapper"]').should('have.class', 'active'); + + // tag must be loaded + cy.get('ds-item-collection-mapper').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-item-collection-mapper'); + + // Click on the "Map new collections" tab + cy.get('li[data-test="mapTab"] a').click(); + + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); + + // Analyze entire page (again) for accessibility issues + testA11y('ds-item-collection-mapper'); + }); +}); diff --git a/cypress/e2e/item-page.cy.ts b/cypress/e2e/item-page.cy.ts index 9dba6eb8cea..b79b6ac31d1 100644 --- a/cypress/e2e/item-page.cy.ts +++ b/cypress/e2e/item-page.cy.ts @@ -1,33 +1,32 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Page', () => { - const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION); - const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); + const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); - // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] - it('should redirect to the entity page when navigating to an item page', () => { - cy.visit(ITEMPAGE); - cy.location('pathname').should('eq', ENTITYPAGE); - }); + // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] + it('should redirect to the entity page when navigating to an item page', () => { + cy.visit(ITEMPAGE); + cy.location('pathname').should('eq', ENTITYPAGE); + }); - it('should pass accessibility tests', () => { - cy.visit(ENTITYPAGE); + it('should pass accessibility tests', () => { + cy.visit(ENTITYPAGE); - // tag must be loaded - cy.get('ds-item-page').should('be.visible'); + // tag must be loaded + cy.get('ds-item-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-item-page'); - }); + // Analyze for accessibility issues + testA11y('ds-item-page'); + }); - it('should pass accessibility tests on full item page', () => { - cy.visit(ENTITYPAGE + '/full'); + it('should pass accessibility tests on full item page', () => { + cy.visit(ENTITYPAGE + '/full'); - // tag must be loaded - cy.get('ds-full-item-page').should('be.visible'); + // tag must be loaded + cy.get('ds-full-item-page').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-full-item-page'); - }); + // Analyze for accessibility issues + testA11y('ds-full-item-page'); + }); }); diff --git a/cypress/e2e/item-statistics.cy.ts b/cypress/e2e/item-statistics.cy.ts index 9b90cb24afc..6518f595a90 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -1,43 +1,43 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Statistics Page', () => { - const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION); - - it('should load if you click on "Statistics" from an Item/Entity page', () => { - cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); - }); - - it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { - cy.visit(ITEMSTATISTICSPAGE); - cy.get('ds-item-statistics-page').should('be.visible'); - cy.get('ds-item-page').should('not.exist'); - }); - - it('should contain a "Total visits" section', () => { - cy.visit(ITEMSTATISTICSPAGE); - cy.get('table[data-test="TotalVisits"]').should('be.visible'); - }); - - it('should contain a "Total visits per month" section', () => { - cy.visit(ITEMSTATISTICSPAGE); - // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_ENTITY_PUBLICATION).concat('_TotalVisitsPerMonth')).should('exist'); - }); - - it('should pass accessibility tests', () => { - cy.visit(ITEMSTATISTICSPAGE); - - // tag must be loaded - cy.get('ds-item-statistics-page').should('be.visible'); - - // Verify / wait until "Total Visits" table's label is non-empty - // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) - cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); - - // Analyze for accessibility issues - testA11y('ds-item-statistics-page'); - }); + const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + + it('should load if you click on "Statistics" from an Item/Entity page', () => { + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); + cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); + }); + + it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('ds-item-statistics-page').should('be.visible'); + cy.get('ds-item-page').should('not.exist'); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(ITEMSTATISTICSPAGE); + // Check just for existence because this table is empty in CI environment as it's historical data + cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(ITEMSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-item-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + + // Analyze for accessibility issues + testA11y('ds-item-statistics-page'); + }); }); diff --git a/cypress/e2e/item-template.cy.ts b/cypress/e2e/item-template.cy.ts new file mode 100644 index 00000000000..5f5b21a16ab --- /dev/null +++ b/cypress/e2e/item-template.cy.ts @@ -0,0 +1,15 @@ +const ADD_TEMPLATE_ITEM_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/itemtemplate'); + +describe('Item Template', () => { + beforeEach(() => { + cy.visit(ADD_TEMPLATE_ITEM_PAGE); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should load properly', () => { + cy.contains('.ds-header-row .lbl-cell', 'Field', { timeout: 10000 }).should('exist').should('be.visible'); + cy.contains('.ds-header-row b', 'Value', { timeout: 10000 }).should('exist').should('be.visible'); + cy.contains('.ds-header-row b', 'Lang', { timeout: 10000 }).should('exist').should('be.visible'); + cy.contains('.ds-header-row b', 'Edit', { timeout: 10000 }).should('exist').should('be.visible'); + }); +}); diff --git a/cypress/e2e/login-modal.cy.ts b/cypress/e2e/login-modal.cy.ts index d29c13c2f96..1d72306accd 100644 --- a/cypress/e2e/login-modal.cy.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -1,138 +1,150 @@ -import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; const page = { - openLoginMenu() { - // Click the "Log In" dropdown menu in header - cy.get('ds-themed-navbar [data-test="login-menu"]').click(); - }, - openUserMenu() { - // Once logged in, click the User menu in header - cy.get('ds-themed-navbar [data-test="user-menu"]').click(); - }, - submitLoginAndPasswordByPressingButton(email, password) { - // Enter email - cy.get('ds-themed-navbar [data-test="email"]').type(email); - // Enter password - cy.get('ds-themed-navbar [data-test="password"]').type(password); - // Click login button - cy.get('ds-themed-navbar [data-test="login-button"]').click(); - }, - submitLoginAndPasswordByPressingEnter(email, password) { - // In opened Login modal, fill out email & password, then click Enter - cy.get('ds-themed-navbar [data-test="email"]').type(email); - cy.get('ds-themed-navbar [data-test="password"]').type(password); - cy.get('ds-themed-navbar [data-test="password"]').type('{enter}'); - }, - submitLogoutByPressingButton() { - // This is the POST command that will actually log us out - cy.intercept('POST', '/server/api/authn/logout').as('logout'); - // Click logout button - cy.get('ds-themed-navbar [data-test="logout-button"]').click(); - // Wait until above POST command responds before continuing - // (This ensures next action waits until logout completes) - cy.wait('@logout'); - } + openLoginMenu() { + // Click the "Log In" dropdown menu in header + cy.get('[data-test="login-menu"]').click(); + }, + openUserMenu() { + // Once logged in, click the User menu in header + cy.get('[data-test="user-menu"]').click(); + }, + submitLoginAndPasswordByPressingButton(email, password) { + // Enter email + cy.get('[data-test="email"]').type(email); + // Enter password + cy.get('[data-test="password"]').type(password); + // Click login button + cy.get('[data-test="login-button"]').click(); + }, + submitLoginAndPasswordByPressingEnter(email, password) { + // In opened Login modal, fill out email & password, then click Enter + cy.get('[data-test="email"]').type(email); + cy.get('[data-test="password"]').type(password); + cy.get('[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('[data-test="logout-button"]').click(); + // Wait until above POST command responds before continuing + // (This ensures next action waits until logout completes) + cy.wait('@logout'); + }, }; describe('Login Modal', () => { - it('should login when clicking button & stay on same page', () => { - const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); - cy.visit(ENTITYPAGE); + it('should login when clicking button & stay on same page', () => { + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + cy.visit(ENTITYPAGE); - // Login menu should exist - cy.get('ds-log-in').should('exist'); + // Login menu should exist + cy.get('ds-log-in').should('exist'); - // Login, and the tag should no longer exist - page.openLoginMenu(); - cy.get('.form-login').should('be.visible'); + // Login, and the tag should no longer exist + page.openLoginMenu(); + cy.get('.form-login').should('be.visible'); - page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); - cy.get('ds-log-in').should('not.exist'); + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); - // Verify we are still on the same page - cy.url().should('include', ENTITYPAGE); + // Verify we are still on the same page + cy.url().should('include', ENTITYPAGE); - // Open user menu, verify user menu & logout button now available - page.openUserMenu(); - cy.get('ds-user-menu').should('be.visible'); - cy.get('ds-log-out').should('be.visible'); - }); + // Open user menu, verify user menu & logout button now available + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + cy.get('ds-log-out').should('be.visible'); + }); - it('should login when clicking enter key & stay on same page', () => { - cy.visit('/home'); + it('should login when clicking enter key & stay on same page', () => { + cy.visit('/home'); - // Open login menu in header & verify tag is visible - page.openLoginMenu(); - cy.get('.form-login').should('be.visible'); + // Open login menu in header & verify tag is visible + page.openLoginMenu(); + cy.get('.form-login').should('be.visible'); - // Login, and the tag should no longer exist - page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); - cy.get('.form-login').should('not.exist'); + // Login, and the tag should no longer exist + page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); - // Verify we are still on homepage - cy.url().should('include', '/home'); + // Verify we are still on homepage + cy.url().should('include', '/home'); - // Open user menu, verify user menu & logout button now available - page.openUserMenu(); - cy.get('ds-user-menu').should('be.visible'); - cy.get('ds-log-out').should('be.visible'); - }); + // Open user menu, verify user menu & logout button now available + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + cy.get('ds-log-out').should('be.visible'); + }); - it('should support logout', () => { - // First authenticate & access homepage - cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); - cy.visit('/'); + it('should support logout', () => { + // First authenticate & access homepage + cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.visit('/'); - // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist - cy.get('ds-log-in').should('not.exist'); - cy.get('ds-log-out').should('exist'); + // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist + cy.get('ds-log-in').should('not.exist'); + cy.get('ds-log-out').should('exist'); - // Click logout button - page.openUserMenu(); - page.submitLogoutByPressingButton(); + // Click logout button + page.openUserMenu(); + page.submitLogoutByPressingButton(); - // Verify ds-log-in tag now exists - cy.get('ds-log-in').should('exist'); - cy.get('ds-log-out').should('not.exist'); - }); + // Verify ds-log-in tag now exists + cy.get('ds-log-in').should('exist'); + cy.get('ds-log-out').should('not.exist'); + }); - it('should allow new user registration', () => { - cy.visit('/'); + it('should allow new user registration', () => { + cy.visit('/'); - page.openLoginMenu(); + page.openLoginMenu(); - // Registration link should be visible - cy.get('ds-themed-navbar [data-test="register"]').should('be.visible'); + // Registration link should be visible + cy.get('ds-header [data-test="register"]').should('be.visible'); - // Click registration link & you should go to registration page - cy.get('ds-themed-navbar [data-test="register"]').click(); - cy.location('pathname').should('eq', '/register'); - cy.get('ds-register-email').should('exist'); - }); + // Click registration link & you should go to registration page + cy.get('ds-header [data-test="register"]').click(); + cy.location('pathname').should('eq', '/register'); + cy.get('ds-register-email').should('exist'); - it('should allow forgot password', () => { - cy.visit('/'); + // Test accessibility of this page + testA11y('ds-register-email'); + }); - page.openLoginMenu(); + it('should allow forgot password', () => { + cy.visit('/'); - // Forgot password link should be visible - cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible'); + page.openLoginMenu(); - // 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'); - }); + // Forgot password link should be visible + cy.get('ds-header [data-test="forgot"]').should('be.visible'); - it('should pass accessibility tests', () => { - cy.visit('/'); + // Click link & you should go to Forgot Password page + cy.get('ds-header [data-test="forgot"]').click(); + cy.location('pathname').should('eq', '/forgot'); + cy.get('ds-forgot-email').should('exist'); - page.openLoginMenu(); + // Test accessibility of this page + testA11y('ds-forgot-email'); + }); - cy.get('ds-log-in').should('exist'); + it('should pass accessibility tests in menus', () => { + cy.visit('/'); - // Analyze for accessibility issues - testA11y('ds-log-in'); - }); + // Open login menu & verify accessibility + page.openLoginMenu(); + cy.get('ds-log-in').should('exist'); + testA11y('ds-log-in'); + + // Now login + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); + + // Open user menu, verify user menu accesibility + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + testA11y('ds-user-menu'); + }); }); diff --git a/cypress/e2e/metadata-import-page.cy.ts b/cypress/e2e/metadata-import-page.cy.ts new file mode 100644 index 00000000000..a31c18e4ebb --- /dev/null +++ b/cypress/e2e/metadata-import-page.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Metadata Import Page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/metadata-import'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Metadata import form must first be visible + cy.get('ds-metadata-import-page').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-metadata-import-page'); + }); +}); diff --git a/cypress/e2e/metadata-registry.cy.ts b/cypress/e2e/metadata-registry.cy.ts new file mode 100644 index 00000000000..0402d33153e --- /dev/null +++ b/cypress/e2e/metadata-registry.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Metadata Registry', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/registries/metadata'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-metadata-registry').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-metadata-registry'); + }); +}); diff --git a/cypress/e2e/metadata-schema.cy.ts b/cypress/e2e/metadata-schema.cy.ts new file mode 100644 index 00000000000..9ff0db0714b --- /dev/null +++ b/cypress/e2e/metadata-schema.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Metadata Schema', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/registries/metadata/dc'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-metadata-schema').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-metadata-schema'); + }); +}); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index 13f4a1b5471..159bb4f5e65 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -1,141 +1,134 @@ -import { Options } from 'cypress-axe'; -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('My DSpace page', () => { - it('should display recent submissions and pass accessibility tests', () => { - cy.visit('/mydspace'); + it('should display recent submissions and pass accessibility tests', () => { + cy.visit('/mydspace'); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - cy.get('ds-my-dspace-page').should('be.visible'); + cy.get('ds-my-dspace-page').should('be.visible'); - // At least one recent submission should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); + // At least one recent submission should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); - // Click each filter toggle to open *every* filter - // (As we want to scan filter section for accessibility issues as well) - cy.get('.filter-toggle').click({ multiple: true }); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('.filter-toggle').click({ multiple: true }); - // Analyze for accessibility issues - testA11y('ds-my-dspace-page'); - }); + // Analyze for accessibility issues + testA11y('ds-my-dspace-page'); + }); - it('should have a working detailed view that passes accessibility tests', () => { - cy.visit('/mydspace'); + 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); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - cy.get('ds-my-dspace-page').should('be.visible'); + cy.get('ds-my-dspace-page').should('be.visible'); - // Click button in sidebar to display detailed view - cy.get('ds-search-sidebar [data-test="detail-view"]').click(); + // Click button in sidebar to display detailed view + cy.get('ds-search-sidebar [data-test="detail-view"]').click(); - cy.get('ds-object-detail').should('be.visible'); + cy.get('ds-object-detail').should('be.visible'); - // 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 - ); - }); + // Analyze for accessibility issues + testA11y('ds-my-dspace-page'); + }); - // NOTE: Deleting existing submissions is exercised by submission.spec.ts - it('should let you start a new submission & edit in-progress submissions', () => { - cy.visit('/mydspace'); + // 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); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - // Open the New Submission dropdown - cy.get('button[data-test="submission-dropdown"]').click(); - // Click on the "Item" type in that dropdown - cy.get('#entityControlsDropdownMenu button[title="none"]').click(); + // 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'); + // 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); + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); - // Click on the button matching that known Collection name - cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click(); + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click(); - // New URL should include /workspaceitems, as we've started a new submission - cy.url().should('include', '/workspaceitems'); + // 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'); + // 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); + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); - // Now that we've created a submission, we'll test that we can go back and Edit it. - // Get our Submission URL, to parse out the ID of this new submission - cy.location().then(fullUrl => { - // This will be the full path (/workspaceitems/[id]/edit) - const path = fullUrl.pathname; - // Split on the slashes - const subpaths = path.split('/'); - // Part 2 will be the [id] of the submission - const id = subpaths[2]; + // 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(); + // 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'); + // "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}); + // 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(); + // 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'); + // 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(); + // 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'); + // 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(); + // 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'); - }); + // Discarding should send us back to MyDSpace + cy.url().should('include', '/mydspace'); }); + }); - it('should let you import from external sources', () => { - cy.visit('/mydspace'); + it('should let you import from external sources', () => { + cy.visit('/mydspace'); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - // 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(); + // 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'); + // 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'); - }); + // The external import searchbox should be visible + cy.get('ds-submission-import-external-searchbar').should('be.visible'); + + // Test for accessibility issues + testA11y('ds-submission-import-external'); + }); }); diff --git a/cypress/e2e/new-process.cy.ts b/cypress/e2e/new-process.cy.ts new file mode 100644 index 00000000000..d26da7cc4df --- /dev/null +++ b/cypress/e2e/new-process.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('New Process', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/processes/new'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Process form must first be visible + cy.get('ds-new-process').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-new-process'); + }); +}); diff --git a/cypress/e2e/pagenotfound.cy.ts b/cypress/e2e/pagenotfound.cy.ts index d02aa8541c3..968ae2747b5 100644 --- a/cypress/e2e/pagenotfound.cy.ts +++ b/cypress/e2e/pagenotfound.cy.ts @@ -1,18 +1,18 @@ import { testA11y } from 'cypress/support/utils'; describe('PageNotFound', () => { - it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { - // request an invalid page (UUIDs at root path aren't valid) - cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); - cy.get('ds-pagenotfound').should('be.visible'); + it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { + // request an invalid page (UUIDs at root path aren't valid) + cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); + cy.get('ds-pagenotfound').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-pagenotfound'); - }); + // Analyze for accessibility issues + testA11y('ds-pagenotfound'); + }); - it('should not contain element ds-pagenotfound when navigating to existing page', () => { - cy.visit('/home'); - cy.get('ds-pagenotfound').should('not.exist'); - }); + it('should not contain element ds-pagenotfound when navigating to existing page', () => { + cy.visit('/home'); + cy.get('ds-pagenotfound').should('not.exist'); + }); }); diff --git a/cypress/e2e/privacy.cy.ts b/cypress/e2e/privacy.cy.ts new file mode 100644 index 00000000000..16e049f701e --- /dev/null +++ b/cypress/e2e/privacy.cy.ts @@ -0,0 +1,13 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Privacy', () => { + it('should pass accessibility tests', () => { + cy.visit('/info/privacy'); + + // Page must first be visible + cy.get('ds-privacy').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-privacy'); + }); +}); diff --git a/cypress/e2e/processes-overview.cy.ts b/cypress/e2e/processes-overview.cy.ts new file mode 100644 index 00000000000..2be3bd4c181 --- /dev/null +++ b/cypress/e2e/processes-overview.cy.ts @@ -0,0 +1,17 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Processes Overview', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/processes'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + + // Process overview must first be visible + cy.get('ds-process-overview').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-process-overview'); + }); +}); diff --git a/cypress/e2e/profile-page.cy.ts b/cypress/e2e/profile-page.cy.ts new file mode 100644 index 00000000000..911ef33ba58 --- /dev/null +++ b/cypress/e2e/profile-page.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Profile page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/profile'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Process form must first be visible + cy.get('ds-profile-page').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-profile-page'); + }); +}); diff --git a/cypress/e2e/quality-assurance-source-page.cy.ts b/cypress/e2e/quality-assurance-source-page.cy.ts new file mode 100644 index 00000000000..722917ef16b --- /dev/null +++ b/cypress/e2e/quality-assurance-source-page.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Quality Assurance Source Page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/notifications/quality-assurance'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Source page must first be visible + cy.get('ds-quality-assurance-source-page-component').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-quality-assurance-source-page-component'); + }); +}); diff --git a/cypress/e2e/search-navbar.cy.ts b/cypress/e2e/search-navbar.cy.ts index 648db17fe65..0613e5e7124 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,66 +1,64 @@ -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; - const page = { - fillOutQueryInNavBar(query) { - // Click the magnifying glass - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); - // Fill out a query in input that appears - cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); - }, - submitQueryByPressingEnter() { - cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); - }, - submitQueryByPressingIcon() { - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); - } + fillOutQueryInNavBar(query) { + // Click the magnifying glass + cy.get('ds-header [data-test="header-search-icon"]').click(); + // Fill out a query in input that appears + cy.get('ds-header [data-test="header-search-box"]').type(query); + }, + submitQueryByPressingEnter() { + cy.get('ds-header [data-test="header-search-box"]').type('{enter}'); + }, + submitQueryByPressingIcon() { + cy.get('ds-header [data-test="header-search-icon"]').click(); + }, }; describe('Search from Navigation Bar', () => { - // NOTE: these tests currently assume this query will return results! - const query = TEST_SEARCH_TERM; + // NOTE: these tests currently assume this query will return results! + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); - it('should go to search page with correct query if submitted (from home)', () => { - cy.visit('/'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // Run the search - page.fillOutQueryInNavBar(query); - page.submitQueryByPressingEnter(); - // New URL should include query param - cy.url().should('include', 'query='.concat(query)); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); - }); + it('should go to search page with correct query if submitted (from home)', () => { + cy.visit('/'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search + page.fillOutQueryInNavBar(query); + page.submitQueryByPressingEnter(); + // New URL should include query param + cy.url().should('include', 'query='.concat(query)); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + }); - it('should go to search page with correct query if submitted (from search)', () => { - cy.visit('/search'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // Run the search - page.fillOutQueryInNavBar(query); - page.submitQueryByPressingEnter(); - // New URL should include query param - cy.url().should('include', 'query='.concat(query)); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); - }); + it('should go to search page with correct query if submitted (from search)', () => { + cy.visit('/search'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search + page.fillOutQueryInNavBar(query); + page.submitQueryByPressingEnter(); + // New URL should include query param + cy.url().should('include', 'query='.concat(query)); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + }); - it('should allow user to also submit query by clicking icon', () => { - cy.visit('/'); - // This is the GET command that will actually run the search - cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); - // Run the search - page.fillOutQueryInNavBar(query); - page.submitQueryByPressingIcon(); - // New URL should include query param - cy.url().should('include', 'query='.concat(query)); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); - }); + it('should allow user to also submit query by clicking icon', () => { + cy.visit('/'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search + page.fillOutQueryInNavBar(query); + page.submitQueryByPressingIcon(); + // New URL should include query param + cy.url().should('include', 'query='.concat(query)); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + }); }); diff --git a/cypress/e2e/search-page.cy.ts b/cypress/e2e/search-page.cy.ts index 755f8eaac6c..62e73c38772 100644 --- a/cypress/e2e/search-page.cy.ts +++ b/cypress/e2e/search-page.cy.ts @@ -1,56 +1,57 @@ -import { Options } from 'cypress-axe'; -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; describe('Search Page', () => { - it('should redirect to the correct url when query was set and submit button was triggered', () => { - const queryString = 'Another interesting query string'; - cy.visit('/search'); - // Type query in searchbox & click search button - cy.get('[data-test="search-box"]').type(queryString); - cy.get('[data-test="search-button"]').click(); - cy.url().should('include', 'query=' + encodeURI(queryString)); - }); + // NOTE: these tests currently assume this query will return results! + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); + + it('should redirect to the correct url when query was set and submit button was triggered', () => { + const queryString = 'Another interesting query string'; + cy.visit('/search'); + // Type query in searchbox & click search button + cy.get('[data-test="search-box"]').type(queryString); + cy.get('[data-test="search-button"]').click(); + cy.url().should('include', 'query=' + encodeURI(queryString)); + }); - it('should load results and pass accessibility tests', () => { - cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); - cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); + it('should load results and pass accessibility tests', () => { + cy.visit('/search?query='.concat(query)); + cy.get('[data-test="search-box"]').should('have.value', query); - // tag must be loaded - cy.get('ds-search-page').should('be.visible'); + // tag must be loaded + cy.get('ds-search-page').should('be.visible'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); - // Click each filter toggle to open *every* filter - // (As we want to scan filter section for accessibility issues as well) - cy.get('[data-test="filter-toggle"]').click({ multiple: true }); + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); - // Analyze for accessibility issues - testA11y('ds-search-page'); - }); + // Analyze for accessibility issues + testA11y('ds-search-page'); + }); - it('should have a working grid view that passes accessibility tests', () => { - cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); + it('should have a working grid view that passes accessibility tests', () => { + cy.visit('/search?query='.concat(query)); - // Click button in sidebar to display grid view - cy.get('ds-search-sidebar [data-test="grid-view"]').click(); + // Click button in sidebar to display grid view + cy.get('ds-search-sidebar [data-test="grid-view"]').click(); - // tag must be loaded - cy.get('ds-search-page').should('be.visible'); + // tag must be loaded + cy.get('ds-search-page').should('be.visible'); - // At least one grid object (card) should be displayed - cy.get('[data-test="grid-object"]').should('be.visible'); + // At least one grid object (card) should be displayed + cy.get('[data-test="grid-object"]').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-search-page', + // Analyze for accessibility issues + testA11y('ds-search-page', { - rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } - } - } as Options - ); - }); + rules: { + // Card titles fail this test currently + 'heading-order': { enabled: false }, + }, + } as Options, + ); + }); }); diff --git a/cypress/e2e/submission.cy.ts b/cypress/e2e/submission.cy.ts index ed10b2d13aa..0ac7003a8b4 100644 --- a/cypress/e2e/submission.cy.ts +++ b/cypress/e2e/submission.cy.ts @@ -1,134 +1,227 @@ -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; +//import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID, TEST_ADMIN_USER, TEST_ADMIN_PASSWORD } from 'cypress/support/e2e'; +import { Options } from 'cypress-axe'; 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='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); - - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(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(); + // NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts + it('should create a new submission when using /submit path & pass accessibility', () => { + // Test that calling /submit with collection & entityType will create a new submission + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + // 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', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); + + // 4 sections should be visible by default + cy.get('div#section_traditionalpageone').should('be.visible'); + cy.get('div#section_traditionalpagetwo').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); + + // Test entire page for accessibility + testA11y('ds-submission-edit', + { + rules: { + // Author & Subject fields have invalid "aria-multiline" attrs. + // See https://github.com/DSpace/dspace-angular/issues/1272 + 'aria-allowed-attr': { enabled: false }, + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + // All select boxes fail to have a name / aria-label. + // This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216 + 'select-name': { enabled: false }, + }, + + } as Options, + ); + + // Discard button should work + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); + + it('should block submission & show errors if required fields are missing', () => { + // Create a new submission + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + // Attempt an immediate deposit without filling out any fields + cy.get('button#deposit').click(); + + // A warning alert should display. + cy.get('ds-notification div.alert-success').should('not.exist'); + cy.get('ds-notification div.alert-warning').should('be.visible'); + + // First section should have an exclamation error in the header + // (as it has required fields) + cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible'); + + // Title field should have class "is-invalid" applied, as it's required + cy.get('input#dc_title').should('have.class', 'is-invalid'); + + // Date Year field should also have "is-valid" class + cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid'); + + // FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button. + // Get our Submission URL, to parse out the ID of this submission + cy.location().then(fullUrl => { + // This will be the full path (/workspaceitems/[id]/edit) + const path = fullUrl.pathname; + // Split on the slashes + const subpaths = path.split('/'); + // Part 2 will be the [id] of the submission + const id = subpaths[2]; + + // Even though form is incomplete, the "Save for Later" button should still work + cy.get('button#saveForLater').click(); + + // "Save for Later" should send us to MyDSpace + cy.url().should('include', '/mydspace'); + + // A success alert should be visible + cy.get('ds-notification div.alert-success').should('be.visible'); + // Now, dismiss any open alert boxes (may be multiple, as tests run quickly) + cy.get('[data-dismiss="alert"]').click({ multiple: true }); + + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // On MyDSpace, find the submission we just saved via its ID + cy.get('[data-test="search-box"]').type(id); + cy.get('[data-test="search-button"]').click(); + + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + + // Delete our created submission & confirm deletion + cy.get('button#delete_' + id).click(); + cy.get('button#delete_confirm').click(); }); + }); - it('should block submission & show errors if required fields are missing', () => { - // Create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + it('should allow for deposit if all required fields completed & file uploaded', () => { + // Create a new submission + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); - // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); - // Attempt an immediate deposit without filling out any fields - cy.get('button#deposit').click(); + // 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'); - // 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'); + // 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 } ); - // 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'); + // 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'); - // Title field should have class "is-invalid" applied, as it's required - cy.get('input#dc_title').should('have.class', 'is-invalid'); + // This is the POST command that will upload the file + cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload'); - // Date Year field should also have "is-valid" class - cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid'); + // 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.svg', { + action: 'drag-drop', + }); - // 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]; + // Wait for upload to complete before proceeding + cy.wait('@upload'); - // Even though form is incomplete, the "Save for Later" button should still work - cy.get('button#saveForLater').click(); + // Wait for deposit button to not be disabled & click it. + cy.get('button#deposit').should('not.be.disabled').click(); - // "Save for Later" should send us to MyDSpace - cy.url().should('include', '/mydspace'); + // No warnings should exist. Instead, just successful deposit alert is displayed + cy.get('ds-notification div.alert-warning').should('not.exist'); + cy.get('ds-notification div.alert-success').should('be.visible'); + }); - // A 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}); + it('is possible to submit a new "Person" and that form passes accessibility', () => { + // To submit a different entity type, we'll start from MyDSpace + cy.visit('/mydspace'); - // This 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(); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + // NOTE: At this time, we MUST login as admin to submit Person objects + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); - // Wait for search results to come back from the above GET command - cy.wait('@search-results'); + // Open the New Submission dropdown + cy.get('button[data-test="submission-dropdown"]').click(); + // Click on the "Person" type in that dropdown + cy.get('#entityControlsDropdownMenu button[title="Person"]').click(); - // Delete our created submission & confirm deletion - cy.get('button#delete_' + id).click(); - cy.get('button#delete_confirm').click(); - }); - }); + // This should display the (popup window) + cy.get('ds-create-item-parent-selector').should('be.visible'); - it('should allow for deposit if all required fields completed & file uploaded', () => { - // Create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); - // 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); + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click(); - // 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'); + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); - // 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} ); + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); - // 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'); + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); - // This is the POST command that will upload the file - cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload'); + // 3 sections should be visible by default + cy.get('div#section_personStep').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); - // 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' - }); + // Test entire page for accessibility + testA11y('ds-submission-edit', + { + rules: { + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + }, - // Wait for upload to complete before proceeding - cy.wait('@upload'); + } as Options, + ); - // Wait for deposit button to not be disabled & click it. - cy.get('button#deposit').should('not.be.disabled').click(); + // Click the lookup button next to "Publication" field + cy.get('button[data-test="lookup-button"]').click(); - // No warnings should exist. Instead, just successful deposit alert is displayed - cy.get('ds-notification div.alert-warning').should('not.exist'); - cy.get('ds-notification div.alert-success').should('be.visible'); + // A popup modal window should be visible + cy.get('ds-dynamic-lookup-relation-modal').should('be.visible'); + + // Popup modal should also pass accessibility tests + //testA11y('ds-dynamic-lookup-relation-modal'); + testA11y({ + include: ['ds-dynamic-lookup-relation-modal'], + exclude: [ + ['ul.nav-tabs'], // Tabs at top of model have several issues which seem to be caused by ng-bootstrap + ], }); + // Close popup window + cy.get('ds-dynamic-lookup-relation-modal button.close').click(); + + // Back on the form, click the discard button to remove new submission + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); }); diff --git a/cypress/e2e/system-wide-alert.cy.ts b/cypress/e2e/system-wide-alert.cy.ts new file mode 100644 index 00000000000..046bfe619fe --- /dev/null +++ b/cypress/e2e/system-wide-alert.cy.ts @@ -0,0 +1,16 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('System Wide Alert', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/admin/system-wide-alert'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-system-wide-alert-form').should('be.visible'); + // Analyze for accessibility issues + testA11y('ds-system-wide-alert-form'); + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index ead38afb921..091f11d0f7e 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -1,35 +1,59 @@ const fs = require('fs'); +// These two global variables are used to store information about the REST API used +// by these e2e tests. They are filled out prior to running any tests in the before() +// method of e2e.ts. They can then be accessed by any tests via the getters below. +let REST_BASE_URL: string; +let REST_DOMAIN: string; + // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // For more info, visit https://on.cypress.io/plugins-api module.exports = (on, config) => { - on('task', { - // Define "log" and "table" tasks, used for logging accessibility errors during CI - // Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file - log(message: string) { - console.log(message); - return null; - }, - table(message: string) { - console.table(message); - return null; - }, - // Cypress doesn't have access to the running application in Node.js. - // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. - // Instead, we'll read our running application's config.json, which contains the configs & - // is regenerated at runtime each time the Angular UI application starts up. - readUIConfig() { - // Check if we have a config.json in the src/assets. If so, use that. - // This is where it's written when running "ng e2e" or "yarn serve" - if (fs.existsSync('./src/assets/config.json')) { - return fs.readFileSync('./src/assets/config.json', 'utf8'); - // Otherwise, check the dist/browser/assets - // This is where it's written when running "serve:ssr", which is what CI uses to start the frontend - } else if (fs.existsSync('./dist/browser/assets/config.json')) { - return fs.readFileSync('./dist/browser/assets/config.json', 'utf8'); - } + on('task', { + // Define "log" and "table" tasks, used for logging accessibility errors during CI + // Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file + log(message: string) { + console.log(message); + return null; + }, + table(message: string) { + console.table(message); + return null; + }, + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + readUIConfig() { + // Check if we have a config.json in the src/assets. If so, use that. + // This is where it's written when running "ng e2e" or "yarn serve" + if (fs.existsSync('./src/assets/config.json')) { + return fs.readFileSync('./src/assets/config.json', 'utf8'); + // Otherwise, check the dist/browser/assets + // This is where it's written when running "serve:ssr", which is what CI uses to start the frontend + } else if (fs.existsSync('./dist/browser/assets/config.json')) { + return fs.readFileSync('./dist/browser/assets/config.json', 'utf8'); + } - return null; - } - }); + return null; + }, + // Save value of REST Base URL, looked up before all tests. + // This allows other tests to use it easily via getRestBaseURL() below. + saveRestBaseURL(url: string) { + return (REST_BASE_URL = url); + }, + // Retrieve currently saved value of REST Base URL + getRestBaseURL() { + return REST_BASE_URL ; + }, + // Save value of REST Domain, looked up before all tests. + // This allows other tests to use it easily via getRestBaseDomain() below. + saveRestBaseDomain(domain: string) { + return (REST_DOMAIN = domain); + }, + // Retrieve currently saved value of REST Domain + getRestBaseDomain() { + return REST_DOMAIN ; + }, + }); }; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c70c4e37e12..8cc2c5c721b 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -3,13 +3,15 @@ // See docs at https://docs.cypress.io/api/cypress-api/custom-commands // *********************************************** -import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; -import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; - -// 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 'login()'. -export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; -export const FALLBACK_TEST_REST_DOMAIN = 'localhost'; +import { + AuthTokenInfo, + TOKENITEM, +} from 'src/app/core/auth/models/auth-token-info.model'; +import { + DSPACE_XSRF_COOKIE, + XSRF_REQUEST_HEADER, +} from 'src/app/core/xsrf/xsrf.constants'; +import { v4 as uuidv4 } from 'uuid'; // Declare Cypress namespace to help with Intellisense & code completion in IDEs // ALL custom commands MUST be listed here for code completion to work @@ -41,6 +43,13 @@ declare global { * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; + + /** + * Create a new CSRF token and add to required Cookie. CSRF Token is returned + * in chainable in order to allow it to be sent also in required CSRF header. + * @returns Chainable reference to allow CSRF token to also be sent in header. + */ + createCSRFCookie(): Chainable; } } } @@ -54,60 +63,33 @@ declare global { * @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: ".concat(config.rest.baseUrl)); - baseRestUrl = config.rest.baseUrl; - } - - // Now find domain of our REST API, again with a fallback. - let baseDomain = FALLBACK_TEST_REST_DOMAIN; - if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); - } else { - baseDomain = config.rest.host; - } - - // Create a fake CSRF Token. Set it in the required server-side cookie - const csrfToken = 'fakeLoginCSRFToken'; - cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - - // Now, send login POST request including that CSRF token - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { [XSRF_REQUEST_HEADER]: csrfToken}, - form: true, // indicates the body should be form urlencoded - body: { user: email, password: password } - }).then((resp) => { - // We expect a successful login - expect(resp.status).to.eq(200); - // We expect to have a valid authorization header returned (with our auth token) - expect(resp.headers).to.have.property('authorization'); - - // Initialize our AuthTokenInfo object from the authorization header. - const authheader = resp.headers.authorization as string; - const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); - - // 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)); - }); - - // Remove cookie with fake CSRF token, as it's no longer needed - cy.clearCookie(DSPACE_XSRF_COOKIE); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken }, + form: true, // indicates the body should be form urlencoded + body: { user: email, password: password }, + }).then((resp) => { + // We expect a successful login + expect(resp.status).to.eq(200); + // We expect to have a valid authorization header returned (with our auth token) + expect(resp.headers).to.have.property('authorization'); + + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + + // 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); @@ -118,12 +100,12 @@ Cypress.Commands.add('login', login); * @param password password to login as */ function loginViaForm(email: string, password: string): void { - // Enter email - cy.get('ds-log-in [data-test="email"]').type(email); - // Enter password - cy.get('ds-log-in [data-test="password"]').type(password); - // Click login button - cy.get('ds-log-in [data-test="login-button"]').click(); + // Enter email + cy.get('[data-test="email"]').type(email); + // Enter password + cy.get('[data-test="password"]').type(password); + // Click login button + cy.get('[data-test="login-button"]').click(); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); @@ -141,54 +123,53 @@ Cypress.Commands.add('loginViaForm', loginViaForm); * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ function generateViewEvent(uuid: string, dsoType: 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 { - baseRestUrl = config.rest.baseUrl; - } - - // Now find domain of our REST API, again with a fallback. - let baseDomain = FALLBACK_TEST_REST_DOMAIN; - if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); - } else { - baseDomain = config.rest.host; - } - - // Create a fake CSRF Token. Set it in the required server-side cookie - const csrfToken = 'fakeGenerateViewEventCSRFToken'; - cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - - // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/statistics/viewevents', - headers: { - [XSRF_REQUEST_HEADER] : csrfToken, - // use a known public IP address to avoid being seen as a "bot" - 'X-Forwarded-For': '1.1.1.1', - }, - //form: true, // indicates the body should be form urlencoded - body: { targetId: uuid, targetType: dsoType }, - }).then((resp) => { - // We expect a 201 (which means statistics event was created) - expect(resp.status).to.eq(201); - }); - - // Remove cookie with fake CSRF token, as it's no longer needed - cy.clearCookie(DSPACE_XSRF_COOKIE); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/statistics/viewevents', + headers: { + [XSRF_REQUEST_HEADER] : csrfToken, + // use a known public IP address to avoid being seen as a "bot" + 'X-Forwarded-For': '1.1.1.1', + // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot" + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + }, + //form: true, // indicates the body should be form urlencoded + body: { targetId: uuid, targetType: dsoType }, + }).then((resp) => { + // We expect a 201 (which means statistics event was created) + expect(resp.status).to.eq(201); + }); }); + }); } // Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') Cypress.Commands.add('generateViewEvent', generateViewEvent); + +/** + * Can be used by tests to generate a random XSRF/CSRF token and save it to + * the required XSRF/CSRF cookie for usage when sending POST requests or similar. + * The generated CSRF token is returned in a Chainable to allow it to be also sent + * in the CSRF HTTP Header. + * @returns a Cypress Chainable which can be used to get the generated CSRF Token + */ +function createCSRFCookie(): Cypress.Chainable { + // Generate a new token which is a random UUID + const csrfToken: string = uuidv4(); + + // Save it to our required cookie + cy.task('getRestBaseDomain').then((baseDomain: string) => { + // Create a fake CSRF Token. Set it in the required server-side cookie + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); + }); + + // return the generated token wrapped in a chainable + return cy.wrap(csrfToken); +} +// Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie') +Cypress.Commands.add('createCSRFCookie', createCSRFCookie); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index dd7ee1824c4..73d3c76a990 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -15,49 +15,57 @@ // 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}'); -}); +import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants'; + +// Runs once before all tests +before(() => { + // 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); -// 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'; - }); -});*/ + // Find URL of our REST API & save to global variable via task + let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; + if (!config.rest.baseUrl) { + console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); + } else { + baseRestUrl = config.rest.baseUrl; + } + cy.task('saveRestBaseURL', baseRestUrl); + // Find domain of our REST API & save to global variable via task. + let baseDomain = FALLBACK_TEST_REST_DOMAIN; + if (!config.rest.host) { + console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); + } else { + baseDomain = config.rest.host; + } + cy.task('saveRestBaseDomain', baseDomain); -// Global constants used in tests -// 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) + }); +}); -// 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'; +// 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}'); + + // Remove any CSRF cookies saved from prior tests + cy.clearCookie(DSPACE_XSRF_COOKIE); +}); +// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL +// from the Angular UI's config.json. See 'before()' above. +const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; +const FALLBACK_TEST_REST_DOMAIN = 'localhost'; // USEFUL REGEX for testing diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 96575969e85..9a9ea1121ba 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -5,26 +5,26 @@ import { Options } from 'cypress-axe'; // Uses 'log' and 'table' tasks defined in ../plugins/index.ts // Borrowed from https://github.com/component-driven/cypress-axe#in-your-spec-file function terminalLog(violations: Result[]) { - cy.task( - 'log', - `${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected` - ); - // pluck specific keys to keep the table readable - const violationData = violations.map( - ({ id, impact, description, helpUrl, nodes }) => ({ - id, - impact, - description, - helpUrl, - nodes: nodes.length, - html: nodes.map(node => node.html) - }) - ); + cy.task( + 'log', + `${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`, + ); + // pluck specific keys to keep the table readable + const violationData = violations.map( + ({ id, impact, description, helpUrl, nodes }) => ({ + id, + impact, + description, + helpUrl, + nodes: nodes.length, + html: nodes.map(node => node.html), + }), + ); - // Print violations as an array, since 'node.html' above often breaks table alignment - cy.task('log', violationData); - // Optionally, uncomment to print as a table - // cy.task('table', violationData); + // Print violations as an array, since 'node.html' above often breaks table alignment + cy.task('log', violationData); + // Optionally, uncomment to print as a table + // cy.task('table', violationData); } @@ -32,13 +32,13 @@ function terminalLog(violations: Result[]) { // while also ensuring any violations are logged to the terminal (see terminalLog above) // This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load export const testA11y = (context?: any, options?: Options) => { - cy.injectAxe(); - cy.configureAxe({ - rules: [ - // Disable color contrast checks as they are inaccurate / result in a lot of false positives - // See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast - { id: 'color-contrast', enabled: false }, - ] - }); - cy.checkA11y(context, options, terminalLog); + cy.injectAxe(); + cy.configureAxe({ + rules: [ + // Disable color contrast checks as they are inaccurate / result in a lot of false positives + // See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast + { id: 'color-contrast', enabled: false }, + ], + }); + cy.checkA11y(context, options, terminalLog); }; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 58083003cda..51237b5e954 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -4,10 +4,11 @@ "**/*.ts" ], "compilerOptions": { + "sourceMap": false, "types": [ "cypress", "cypress-axe", "node" ] } -} \ No newline at end of file +} diff --git a/docker/README.md b/docker/README.md index 37d071a86f8..6360124b601 100644 --- a/docker/README.md +++ b/docker/README.md @@ -20,17 +20,17 @@ the Docker compose scripts in this 'docker' folder. ### Dockerfile -This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' +This Dockerfile is used to build a *development* DSpace Angular UI image, published as 'dspace/dspace-angular' ``` -docker build -t dspace/dspace-angular:dspace-7_x . +docker build -t dspace/dspace-angular:dspace-8_x . ``` This image is built *automatically* after each commit is made to the `main` branch. Admins to our DockerHub repo can manually publish with the following command. ``` -docker push dspace/dspace-angular:dspace-7_x +docker push dspace/dspace-angular:dspace-8_x ``` ### Dockerfile.dist @@ -39,18 +39,18 @@ The `Dockerfile.dist` is used to generate a *production* build and runtime envir ```bash # build the latest image -docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist . +docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-8_x-dist . ``` A default/demo version of this image is built *automatically*. ## 'docker' directory - docker-compose.yml - - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. + - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace REST instance will also be started in Docker. - docker-compose-rest.yml - - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes + - Runs a published instance of the DSpace REST API - persists data in Docker volumes - docker-compose-ci.yml - - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. + - Runs a published instance of the DSpace REST API for CI testing. The database is re-populated from a SQL dump on each startup. - cli.yml - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. - cli.assetstore.yml @@ -59,19 +59,19 @@ A default/demo version of this image is built *automatically*. ## To refresh / pull DSpace images from Dockerhub ``` -docker-compose -f docker/docker-compose.yml pull +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 +docker compose -f docker/docker-compose.yml build ``` ## To start DSpace (REST and Angular) from your branch This command provides a quick way to start both the frontend & backend from this single codebase ``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d +docker compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d ``` Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section. @@ -86,14 +86,14 @@ _The system will be started in 2 steps. Each step shares the same docker network From 'DSpace/DSpace' clone (build first as needed): ``` -docker-compose -p d7 up -d +docker compose -p d8 up -d ``` NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md). From 'DSpace/dspace-angular' clone (build first as needed) ``` -docker-compose -p d7 -f docker/docker-compose.yml up -d +docker compose -p d8 -f docker/docker-compose.yml up -d ``` At this point, you should be able to access the UI from http://localhost:4000, @@ -105,21 +105,21 @@ This allows you to run the Angular UI in *production* mode, pointing it at the d (https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/). ``` -docker-compose -f docker/docker-compose-dist.yml pull -docker-compose -f docker/docker-compose-dist.yml build -docker-compose -p d7 -f docker/docker-compose-dist.yml up -d +docker compose -f docker/docker-compose-dist.yml pull +docker compose -f docker/docker-compose-dist.yml build +docker compose -p d8 -f docker/docker-compose-dist.yml up -d ``` ## Ingest test data from AIPDIR Create an administrator ``` -docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en +docker compose -p d8 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en ``` Load content from AIP files ``` -docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli +docker compose -p d8 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli ``` ## Alternative Ingest - Use Entities dataset @@ -127,12 +127,12 @@ _Delete your docker volumes or use a unique project (-p) name_ Start DSpace with Database Content from a database dump ``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d +docker compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d ``` Load assetstore content and trigger a re-index of the repository ``` -docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli +docker compose -p d8 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli ``` ## End to end testing of the REST API (runs in GitHub Actions CI). @@ -140,5 +140,5 @@ _In this instance, only the REST api runs in Docker using the Entities dataset. This command is only really useful for testing our Continuous Integration process. ``` -docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d +docker compose -p d8ci -f docker/docker-compose-ci.yml up -d ``` diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 40e4974c7c7..98f74148610 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -12,15 +12,8 @@ # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.assetstore.yml # # Therefore, it should be kept in sync with that file -version: "3.7" - -networks: - dspacenet: - services: dspace-cli: - networks: - dspacenet: {} environment: # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml index 1db241af3bf..31563ccc083 100644 --- a/docker/cli.ingest.yml +++ b/docker/cli.ingest.yml @@ -12,8 +12,6 @@ # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.ingest.yml # # Therefore, it should be kept in sync with that file -version: "3.7" - services: dspace-cli: environment: @@ -34,5 +32,7 @@ services: /dspace/bin/dspace packager -r -a -t AIP -e $${ADMIN_EMAIL} -f -u SITE*.zip /dspace/bin/dspace database update-sequences + touch /dspace/solr/search/conf/reindex.flag - /dspace/bin/dspace index-discovery + /dspace/bin/dspace oai import + /dspace/bin/dspace oai clean-cache diff --git a/docker/cli.yml b/docker/cli.yml index 54b83d45036..7c17b14b1bd 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -12,11 +12,16 @@ # https://github.com/DSpace/DSpace/blob/main/docker-compose-cli.yml # # Therefore, it should be kept in sync with that file -version: "3.7" - +networks: + # Default to using network named 'dspacenet' from docker-compose-rest.yml. + # Its full name will be prepended with the project name (e.g. "-p d8" means it will be named "d8_dspacenet") + # If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in) + default: + name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet + external: true services: dspace-cli: - image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-8_x}" container_name: dspace-cli environment: # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. @@ -30,16 +35,12 @@ services: # solr.server: Ensure we are using the 'dspacesolr' image for Solr solr__P__server: http://dspacesolr:8983/solr volumes: - - "assetstore:/dspace/assetstore" + # Keep DSpace assetstore directory between reboots + - assetstore:/dspace/assetstore entrypoint: /dspace/bin/dspace command: help - networks: - - dspacenet tty: true stdin_open: true volumes: assetstore: - -networks: - dspacenet: diff --git a/docker/db.entities.yml b/docker/db.entities.yml index 6473bf2e385..464253f07be 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -12,14 +12,13 @@ # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # # # Therefore, it should be kept in sync with that file -version: "3.7" - services: dspacedb: - image: dspace/dspace-postgres-pgcrypto:loadsql + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-8_x}-loadsql" environment: # This LOADSQL should be kept in sync with the URL in DSpace/DSpace # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + # NOTE: currently there is no dspace8 version - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql dspace: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### @@ -29,23 +28,11 @@ services: # 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml # This 'sed' command inserts the sample configurations specific to the Entities data set, see: # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49 - # 4. Finally, start Tomcat + # 4. Finally, start DSpace entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate ignored - sed -i '/name-map collection-handle="default".*/a \\n \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - ' /dspace/config/item-submission.xml - catalina.sh run \ No newline at end of file + java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 9ec8fe664a3..d2589bb3f32 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -10,7 +10,6 @@ # This is used by our GitHub CI at .github/workflows/build.yml # It is based heavily on the Backend's Docker Compose: # https://github.com/DSpace/DSpace/blob/main/docker-compose.yml -version: '3.7' networks: dspacenet: services: @@ -33,11 +32,12 @@ services: # Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. solr__D__statistics__P__autoCommit: 'false' + LOGGING_CONFIG: /dspace/config/log4j2-container.xml + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-8_x-test}" depends_on: - dspacedb - image: dspace/dspace:dspace-7_x-test networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -45,46 +45,46 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) - # 3. Finally, start Tomcat + # 3. Finally, start DSpace entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate ignored - catalina.sh run + java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace # DSpace database container # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data dspacedb: container_name: dspacedb + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-8_x}-loadsql" environment: # This LOADSQL should be kept in sync with the LOADSQL in # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + # NOTE: currently there is no dspace8 version LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql PGDATA: /pgdata - image: dspace/dspace-postgres-pgcrypto:loadsql + POSTGRES_PASSWORD: dspace networks: - dspacenet: + - dspacenet + ports: + - published: 5432 + target: 5432 stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr - # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.11-slim - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-8_x}" networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -92,9 +92,6 @@ services: tty: true working_dir: /var/solr/data volumes: - # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) - # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume - - solr_configs:/opt/solr/server/solr/configsets/dspace # Keep Solr data directory between reboots - solr_data:/var/solr/data # Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr @@ -103,14 +100,20 @@ services: - '-c' - | init-var-solr - precreate-core authority /opt/solr/server/solr/configsets/dspace/authority - precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - precreate-core search /opt/solr/server/solr/configsets/dspace/search - precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + precreate-core authority /opt/solr/server/solr/configsets/authority + cp -r /opt/solr/server/solr/configsets/authority/* authority + precreate-core oai /opt/solr/server/solr/configsets/oai + cp -r /opt/solr/server/solr/configsets/oai/* oai + precreate-core search /opt/solr/server/solr/configsets/search + cp -r /opt/solr/server/solr/configsets/search/* search + precreate-core statistics /opt/solr/server/solr/configsets/statistics + cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent + precreate-core suggestion /opt/solr/server/solr/configsets/suggestion + cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: \ No newline at end of file diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml index 00225e8052a..03e5e9da709 100644 --- a/docker/docker-compose-dist.yml +++ b/docker/docker-compose-dist.yml @@ -8,7 +8,6 @@ # Docker Compose for running the DSpace Angular UI dist build # for previewing with the DSpace Demo site backend -version: '3.7' networks: dspacenet: services: @@ -24,10 +23,10 @@ services: # This is because Server Side Rendering (SSR) currently requires a public URL, # see this bug: https://github.com/DSpace/dspace-angular/issues/1485 DSPACE_REST_SSL: 'true' - DSPACE_REST_HOST: demo.dspace.org + DSPACE_REST_HOST: sandbox.dspace.org DSPACE_REST_PORT: 443 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x-dist + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-8_x}-dist" build: context: .. dockerfile: Dockerfile.dist diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index e5f62600e70..37a5d23e77b 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -10,7 +10,6 @@ # This is based heavily on the docker-compose.yml that is available in the DSpace/DSpace # (Backend) at: # https://github.com/DSpace/DSpace/blob/main/docker-compose.yml -version: '3.7' networks: dspacenet: ipam: @@ -29,8 +28,9 @@ services: # __D__ => "-" (e.g. google__D__metadata => google-metadata) # dspace.dir, dspace.server.url, dspace.ui.url and dspace.name dspace__P__dir: /dspace - dspace__P__server__P__url: http://localhost:8080/server - dspace__P__ui__P__url: http://localhost:4000 + # Uncomment to set a non-default value for dspace.server.url or dspace.ui.url + # dspace__P__server__P__url: http://localhost:8080/server + # dspace__P__ui__P__url: http://localhost:4000 dspace__P__name: 'DSpace Started with Docker Compose' # db.url: Ensure we are using the 'dspacedb' image for our database db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' @@ -39,55 +39,55 @@ services: # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. proxies__P__trusted__P__ipranges: '172.23.0' - image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}" + LOGGING_CONFIG: /dspace/config/log4j2-container.xml + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-8_x-test}" depends_on: - dspacedb networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 stdin_open: true tty: true volumes: + # Keep DSpace assetstore directory between reboots - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables - # 3. Finally, start Tomcat + # 3. Finally, start DSpace entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate - catalina.sh run + java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace # DSpace database container dspacedb: container_name: dspacedb + # Uses a custom Postgres image with pgcrypto installed + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-8_x}" environment: PGDATA: /pgdata - image: dspace/dspace-postgres-pgcrypto + POSTGRES_PASSWORD: dspace networks: - dspacenet: + - dspacenet ports: - published: 5432 target: 5432 stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}" - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-8_x}" networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -101,7 +101,7 @@ services: # * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op # * Second, copy configsets to this core: # Updates to Solr configs require the container to be rebuilt/restarted: - # `docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr` + # `docker compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr` entrypoint: - /bin/bash - '-c' @@ -115,10 +115,12 @@ services: cp -r /opt/solr/server/solr/configsets/search/* search precreate-core statistics /opt/solr/server/solr/configsets/statistics cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent + precreate-core suggestion /opt/solr/server/solr/configsets/suggestion + cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1387b1de396..8e85520f9fa 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,7 +9,6 @@ # Docker Compose for running the DSpace Angular UI for testing/development # Requires also running a REST API backend (either locally or remotely), # for example via 'docker-compose-rest.yml' -version: '3.7' networks: dspacenet: services: @@ -24,7 +23,7 @@ services: DSPACE_REST_HOST: localhost DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-8_x}" build: context: .. dockerfile: Dockerfile diff --git a/docs/lint/html/index.md b/docs/lint/html/index.md new file mode 100644 index 00000000000..e134e1070f4 --- /dev/null +++ b/docs/lint/html/index.md @@ -0,0 +1,5 @@ +[DSpace ESLint plugins](../../../lint/README.md) > HTML rules +_______ + +- [`dspace-angular-html/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via the selector of their `ThemedComponent` wrapper class +- [`dspace-angular-html/no-disabled-attribute-on-button`](./rules/no-disabled-attribute-on-button.md): Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute. diff --git a/docs/lint/html/rules/no-disabled-attribute-on-button.md b/docs/lint/html/rules/no-disabled-attribute-on-button.md new file mode 100644 index 00000000000..d9d39ce82ca --- /dev/null +++ b/docs/lint/html/rules/no-disabled-attribute-on-button.md @@ -0,0 +1,78 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/no-disabled-attribute-on-button` +_______ + +Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute. + This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled. + The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present. + +_______ + +[Source code](../../../../lint/src/rules/html/no-disabled-attribute-on-button.ts) + +### Examples + + +#### Valid code + +##### should use [dsBtnDisabled] in HTML templates + +```html + +``` + +##### disabled attribute is still valid on non-button elements + +```html + +``` + +##### [disabled] attribute is still valid on non-button elements + +```html + +``` + +##### angular dynamic attributes that use disabled are still valid + +```html + +``` + + + + +#### Invalid code & automatic fixes + +##### should not use disabled attribute in HTML templates + +```html + +``` +Will produce the following error(s): +``` +Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute. +``` + +Result of `yarn lint --fix`: +```html + +``` + + +##### should not use [disabled] attribute in HTML templates + +```html + +``` +Will produce the following error(s): +``` +Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute. +``` + +Result of `yarn lint --fix`: +```html + +``` + + + diff --git a/docs/lint/html/rules/themed-component-usages.md b/docs/lint/html/rules/themed-component-usages.md new file mode 100644 index 00000000000..a04fe1c770a --- /dev/null +++ b/docs/lint/html/rules/themed-component-usages.md @@ -0,0 +1,110 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/themed-component-usages` +_______ + +Themeable components should be used via the selector of their `ThemedComponent` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple. + + +_______ + +[Source code](../../../../lint/src/rules/html/themed-component-usages.ts) + +### Examples + + +#### Valid code + +##### use no-prefix selectors in HTML templates + +```html + + + +``` + +##### use no-prefix selectors in TypeScript templates + +```html +@Component({ + template: '' +}) +class Test { +} +``` + +##### use no-prefix selectors in TypeScript test templates + +Filename: `lint/test/fixture/src/test.spec.ts` + +```html +@Component({ + template: '' +}) +class Test { +} +``` + +##### base selectors are also allowed in TypeScript test templates + +Filename: `lint/test/fixture/src/test.spec.ts` + +```html +@Component({ + template: '' +}) +class Test { +} +``` + + + + +#### Invalid code & automatic fixes + +##### themed override selectors are not allowed in HTML templates + +```html + + + +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +``` + +Result of `yarn lint --fix`: +```html + + + +``` + + +##### base selectors are not allowed in HTML templates + +```html + + + +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +Themeable components should be used via their ThemedComponent wrapper's selector +``` + +Result of `yarn lint --fix`: +```html + + + +``` + + + diff --git a/docs/lint/ts/index.md b/docs/lint/ts/index.md new file mode 100644 index 00000000000..ed060c946e8 --- /dev/null +++ b/docs/lint/ts/index.md @@ -0,0 +1,6 @@ +[DSpace ESLint plugins](../../../lint/README.md) > TypeScript rules +_______ + +- [`dspace-angular-ts/themed-component-classes`](./rules/themed-component-classes.md): Formatting rules for themeable component classes +- [`dspace-angular-ts/themed-component-selectors`](./rules/themed-component-selectors.md): Themeable component selectors should follow the DSpace convention +- [`dspace-angular-ts/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via their `ThemedComponent` wrapper class diff --git a/docs/lint/ts/rules/themed-component-classes.md b/docs/lint/ts/rules/themed-component-classes.md new file mode 100644 index 00000000000..1f4ec72801c --- /dev/null +++ b/docs/lint/ts/rules/themed-component-classes.md @@ -0,0 +1,257 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-classes` +_______ + +Formatting rules for themeable component classes + +- All themeable components must be standalone. +- The base component must always be imported in the `ThemedComponent` wrapper. This ensures that it is always sufficient to import just the wrapper whenever we use the component. + + +_______ + +[Source code](../../../../lint/src/rules/ts/themed-component-classes.ts) + +### Examples + + +#### Valid code + +##### Regular non-themeable component + +```typescript +@Component({ + selector: 'ds-something', + standalone: true, +}) +class Something { +} +``` + +##### Base component + +```typescript +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableTomponent { +} +``` + +##### Wrapper component + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + TestThemeableComponent, + ], +}) +class ThemedTestThemeableTomponent extends ThemedComponent { +} +``` + +##### Override component + +Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} +``` + + + + +#### Invalid code & automatic fixes + +##### Base component must be standalone + +```typescript +@Component({ + selector: 'ds-base-test-themable', +}) +class TestThemeableComponent { +} +``` +Will produce the following error(s): +``` +Themeable components must be standalone +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableComponent { +} +``` + + +##### Wrapper component must be standalone and import base component + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-test-themable', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themeable component wrapper classes must be standalone and import the base class +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrapper component must import base component (array present but empty) + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper classes must only import the base class +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrapper component must import base component (array is wrong) + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper classes must only import the base class +``` + +Result of `yarn lint --fix`: +```typescript +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrapper component must import base component (array is wrong) + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper classes must only import the base class +``` + +Result of `yarn lint --fix`: +```typescript +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Override component must be standalone + +Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-themed-test-themable', +}) +class Override extends BaseComponent { +} +``` +Will produce the following error(s): +``` +Themeable components must be standalone +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} +``` + + + diff --git a/docs/lint/ts/rules/themed-component-selectors.md b/docs/lint/ts/rules/themed-component-selectors.md new file mode 100644 index 00000000000..f4d0ea177c9 --- /dev/null +++ b/docs/lint/ts/rules/themed-component-selectors.md @@ -0,0 +1,156 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-selectors` +_______ + +Themeable component selectors should follow the DSpace convention + +Each themeable component is comprised of a base component, a wrapper component and any number of themed components +- Base components should have a selector starting with `ds-base-` +- Themed components should have a selector starting with `ds-themed-` +- Wrapper components should have a selector starting with `ds-`, but not `ds-base-` or `ds-themed-` + - This is the regular DSpace selector prefix + - **When making a regular component themeable, its selector prefix should be changed to `ds-base-`, and the new wrapper's component should reuse the previous selector** + +Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source. + + +_______ + +[Source code](../../../../lint/src/rules/ts/themed-component-selectors.ts) + +### Examples + + +#### Valid code + +##### Regular non-themeable component selector + +```typescript +@Component({ + selector: 'ds-something', +}) +class Something { +} +``` + +##### Themeable component selector should replace the original version, unthemed version should be changed to ds-base- + +```typescript +@Component({ + selector: 'ds-base-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something', +}) +class ThemedSomething extends ThemedComponent { +} + +@Component({ + selector: 'ds-themed-something', +}) +class OverrideSomething extends Something { +} +``` + +##### Other themed component wrappers should not interfere + +```typescript +@Component({ + selector: 'ds-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something-else', +}) +class ThemedSomethingElse extends ThemedComponent { +} +``` + + + + +#### Invalid code & automatic fixes + +##### Wrong selector for base component + +Filename: `lint/test/fixture/src/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-something', +}) +class TestThemeableComponent { +} +``` +Will produce the following error(s): +``` +Unthemed version of themeable component should have a selector starting with 'ds-base-' +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-base-something', +}) +class TestThemeableComponent { +} +``` + + +##### Wrong selector for wrapper component + +Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-themed-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` +Will produce the following error(s): +``` +Themed component wrapper of themeable component shouldn't have a selector starting with 'ds-themed-' +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} +``` + + +##### Wrong selector for theme override + +Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts` + +```typescript +@Component({ + selector: 'ds-something', +}) +class TestThememeableComponent extends BaseComponent { +} +``` +Will produce the following error(s): +``` +Theme override of themeable component should have a selector starting with 'ds-themed-' +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-themed-something', +}) +class TestThememeableComponent extends BaseComponent { +} +``` + + + diff --git a/docs/lint/ts/rules/themed-component-usages.md b/docs/lint/ts/rules/themed-component-usages.md new file mode 100644 index 00000000000..16ccb701c20 --- /dev/null +++ b/docs/lint/ts/rules/themed-component-usages.md @@ -0,0 +1,332 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-usages` +_______ + +Themeable components should be used via their `ThemedComponent` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +There are a few exceptions where the base class can still be used: +- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place) +- Angular modules (except for routing modules) +- Angular `@ViewChild` decorators +- Type annotations + + +_______ + +[Source code](../../../../lint/src/rules/ts/themed-component-usages.ts) + +### Examples + + +#### Valid code + +##### allow wrapper class usages + +```typescript +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: ChipsComponent, +} +``` + +##### allow base class in class declaration + +```typescript +export class TestThemeableComponent { +} +``` + +##### allow inheriting from base class + +```typescript +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class ThemedAdminSidebarComponent extends ThemedComponent { +} +``` + +##### allow base class in ViewChild + +```typescript +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class Something { + @ViewChild(TestThemeableComponent) test: TestThemeableComponent; +} +``` + +##### allow wrapper selectors in test queries + +Filename: `lint/test/fixture/src/app/test/test.component.spec.ts` + +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + +##### allow wrapper selectors in cypress queries + +Filename: `lint/test/fixture/src/app/test/test.component.cy.ts` + +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + + + + +#### Invalid code & automatic fixes + +##### disallow direct usages of base class + +```typescript +import { TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, +} +``` + + +##### disallow direct usages of base class, keep other imports + +```typescript +import { Something, TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, + c: Something, +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { Something } from './app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, + c: Something, +} +``` + + +##### handle array replacements correctly + +```typescript +const DECLARATIONS = [ + Something, + TestThemeableComponent, + Something, + ThemedTestThemeableComponent, +]; +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +const DECLARATIONS = [ + Something, + Something, + ThemedTestThemeableComponent, +]; +``` + + +##### disallow override selector in test queries + +Filename: `lint/test/fixture/src/app/test/test.component.spec.ts` + +```typescript +By.css('ds-themed-themeable'); +By.css('#test > ds-themed-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + + +##### disallow base selector in test queries + +Filename: `lint/test/fixture/src/app/test/test.component.spec.ts` + +```typescript +By.css('ds-base-themeable'); +By.css('#test > ds-base-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); +``` + + +##### disallow override selector in cypress queries + +Filename: `lint/test/fixture/src/app/test/test.component.cy.ts` + +```typescript +cy.get('ds-themed-themeable'); +cy.get('#test > ds-themed-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); +``` + + +##### disallow base selector in cypress queries + +Filename: `lint/test/fixture/src/app/test/test.component.cy.ts` + +```typescript +cy.get('ds-base-themeable'); +cy.get('#test > ds-base-themeable > #nest'); +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); +``` + + +##### edge case: unable to find usage node through usage token, but import is still flagged and fixed + +Filename: `lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts` + +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent], +}) +export class UsageComponent { +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} +``` + + +##### edge case edge case: both are imported, only wrapper is retained + +Filename: `lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts` + +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent, ThemedTestThemeableComponent], +}) +export class UsageComponent { +} +``` +Will produce the following error(s): +``` +Themeable components should be used via their ThemedComponent wrapper +Themeable components should be used via their ThemedComponent wrapper +``` + +Result of `yarn lint --fix`: +```typescript +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} +``` + + + diff --git a/karma.conf.js b/karma.conf.js index 8418312b1ab..f96558bfaff 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,10 @@ module.exports = function (config) { ], client: { clearContext: false, // leave Jasmine Spec Runner output visible in browser - captureConsole: false + captureConsole: false, + jasmine: { + failSpecWithNoExpectations: true + } }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/dspace-angular'), diff --git a/lint/.gitignore b/lint/.gitignore new file mode 100644 index 00000000000..0d22081b3bc --- /dev/null +++ b/lint/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/coverage/ +/node-modules/ diff --git a/lint/README.md b/lint/README.md new file mode 100644 index 00000000000..7251a35c06a --- /dev/null +++ b/lint/README.md @@ -0,0 +1,50 @@ +# DSpace ESLint plugins + +Custom ESLint rules for DSpace Angular peculiarities. + +## Usage + +These plugins are included with the rest of our ESLint configuration in [.eslintc.json](../.eslintrc.json). Individual rules can be configured or disabled there, like usual. +- In order for the new rules to be picked up by your IDE, you should first run `yarn build:lint` to build the plugins. +- This will also happen automatically each time `yarn lint` is run. + +## Documentation + +The rules are split up into plugins by language: +- [TypeScript rules](../docs/lint/ts/index.md) +- [HTML rules](../docs/lint/html/index.md) + +> Run `yarn docs:lint` to generate this documentation! + +## Developing + +### Overview + +- All rules are written in TypeScript and compiled into [`dist`](./dist) + - The plugins are linked into the main project dependencies from here + - These directories already contain the necessary `package.json` files to mark them as ESLint plugins +- Rule source files are structured, so they can be imported all in one go + - Each rule must export the following: + - `Messages`: an Enum of error message IDs + - `info`: metadata about this rule (name, description, messages, options, ...) + - `rule`: the implementation of the rule + - `tests`: the tests for this rule, as a set of valid/invalid code snippets. These snippets are used as example in the documentation. + - New rules should be added to their plugin's `index.ts` +- Some useful links + - [Developing ESLint plugins](https://eslint.org/docs/latest/extend/plugins) + - [Custom rules in typescript-eslint](https://typescript-eslint.io/developers/custom-rules) + - [Angular ESLint](https://github.com/angular-eslint/angular-eslint) + +### Parsing project metadata in advance ~ TypeScript AST + +While it is possible to retain persistent state between files during the linting process, it becomes quite complicated if the content of one file determines how we want to lint another file. +Because the two files may be linted out of order, we may not know whether the first file is wrong before we pass by the second. This means that we cannot report or fix the issue, because the first file is already detached from the linting context. + +For example, we cannot consistently determine which components are themeable (i.e. have a `ThemedComponent` wrapper) while linting. +To work around this issue, we construct a registry of themeable components _before_ linting anything. +- We don't have a good way to hook into the ESLint parser at this time +- Instead, we leverage the actual TypeScript AST parser + - Retrieve all `ThemedComponent` wrapper files by the pattern of their path (`themed-*.component.ts`) + - Determine the themed component they're linked to (by the actual type annotation/import path, since filenames are prone to errors) + - Store metadata describing these component pairs in a global registry that can be shared between rules +- This only needs to happen once, and only takes a fraction of a second (for ~100 themeable components) \ No newline at end of file diff --git a/lint/dist/src/rules/html/package.json b/lint/dist/src/rules/html/package.json new file mode 100644 index 00000000000..d3f310d23b9 --- /dev/null +++ b/lint/dist/src/rules/html/package.json @@ -0,0 +1,6 @@ +{ + "name": "eslint-plugin-dspace-angular-html", + "version": "0.0.0", + "main": "./index.js", + "private": true +} diff --git a/lint/dist/src/rules/ts/package.json b/lint/dist/src/rules/ts/package.json new file mode 100644 index 00000000000..f19e18756ac --- /dev/null +++ b/lint/dist/src/rules/ts/package.json @@ -0,0 +1,6 @@ +{ + "name": "eslint-plugin-dspace-angular-ts", + "version": "0.0.0", + "main": "./index.js", + "private": true +} diff --git a/lint/generate-docs.ts b/lint/generate-docs.ts new file mode 100644 index 00000000000..fb2bf53fb58 --- /dev/null +++ b/lint/generate-docs.ts @@ -0,0 +1,85 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { join } from 'path'; + +import { default as htmlPlugin } from './src/rules/html'; +import { default as tsPlugin } from './src/rules/ts'; + +const templates = new Map(); + +function lazyEJS(path: string, data: object): string { + if (!templates.has(path)) { + templates.set(path, require('ejs').compile(readFileSync(path).toString())); + } + + return templates.get(path)(data).replace(/\r\n/g, '\n'); +} + +const docsDir = join('docs', 'lint'); +const tsDir = join(docsDir, 'ts'); +const htmlDir = join(docsDir, 'html'); + +if (existsSync(docsDir)) { + rmSync(docsDir, { recursive: true }); +} + +mkdirSync(join(tsDir, 'rules'), { recursive: true }); +mkdirSync(join(htmlDir, 'rules'), { recursive: true }); + +function template(name: string): string { + return join('lint', 'src', 'util', 'templates', name); +} + +// TypeScript docs +writeFileSync( + join(tsDir, 'index.md'), + lazyEJS(template('index.ejs'), { + plugin: tsPlugin, + rules: tsPlugin.index.map(rule => rule.info), + }), +); + +for (const rule of tsPlugin.index) { + writeFileSync( + join(tsDir, 'rules', rule.info.name + '.md'), + lazyEJS(template('rule.ejs'), { + plugin: tsPlugin, + rule: rule.info, + tests: rule.tests, + }), + ); +} + +// HTML docs +writeFileSync( + join(htmlDir, 'index.md'), + lazyEJS(template('index.ejs'), { + plugin: htmlPlugin, + rules: htmlPlugin.index.map(rule => rule.info), + }), +); + +for (const rule of htmlPlugin.index) { + writeFileSync( + join(htmlDir, 'rules', rule.info.name + '.md'), + lazyEJS(template('rule.ejs'), { + plugin: htmlPlugin, + rule: rule.info, + tests: rule.tests, + }), + ); +} + diff --git a/lint/jasmine.json b/lint/jasmine.json new file mode 100644 index 00000000000..dfacd41a96c --- /dev/null +++ b/lint/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_files": ["**/*.spec.js"], + "spec_dir": "lint/dist/test", + "helpers": [ + "./test/helpers.js" + ] +} diff --git a/lint/src/rules/html/index.ts b/lint/src/rules/html/index.ts new file mode 100644 index 00000000000..3d425c3ad48 --- /dev/null +++ b/lint/src/rules/html/index.ts @@ -0,0 +1,25 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/* eslint-disable import/no-namespace */ +import { + bundle, + RuleExports, +} from '../../util/structure'; +import * as noDisabledAttributeOnButton from './no-disabled-attribute-on-button'; +import * as themedComponentUsages from './themed-component-usages'; + +const index = [ + themedComponentUsages, + noDisabledAttributeOnButton, + +] as unknown as RuleExports[]; + +export = { + parser: require('@angular-eslint/template-parser'), + ...bundle('dspace-angular-html', 'HTML', index), +}; diff --git a/lint/src/rules/html/no-disabled-attribute-on-button.ts b/lint/src/rules/html/no-disabled-attribute-on-button.ts new file mode 100644 index 00000000000..bf1a72d70d0 --- /dev/null +++ b/lint/src/rules/html/no-disabled-attribute-on-button.ts @@ -0,0 +1,147 @@ +import { + TmplAstBoundAttribute, + TmplAstTextAttribute, +} from '@angular-eslint/bundled-angular-compiler'; +import { TemplateParserServices } from '@angular-eslint/utils'; +import { + ESLintUtils, + TSESLint, +} from '@typescript-eslint/utils'; + +import { + DSpaceESLintRuleInfo, + NamedTests, +} from '../../util/structure'; +import { getSourceCode } from '../../util/typescript'; + +export enum Message { + USE_DSBTN_DISABLED = 'mustUseDsBtnDisabled', +} + +export const info = { + name: 'no-disabled-attribute-on-button', + meta: { + docs: { + description: `Buttons should use the \`dsBtnDisabled\` directive instead of the HTML \`disabled\` attribute. + This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled. + The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present.`, + }, + type: 'problem', + fixable: 'code', + schema: [], + messages: { + [Message.USE_DSBTN_DISABLED]: 'Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext) { + const parserServices = getSourceCode(context).parserServices as TemplateParserServices; + + /** + * Some dynamic angular inputs will have disabled as name because of how Angular handles this internally (e.g [class.disabled]="isDisabled") + * But these aren't actually the disabled attribute we're looking for, we can determine this by checking the details of the keySpan + */ + function isOtherAttributeDisabled(node: TmplAstBoundAttribute | TmplAstTextAttribute): boolean { + // if the details are not null, and the details are not 'disabled', then it's not the disabled attribute we're looking for + return node.keySpan?.details !== null && node.keySpan?.details !== 'disabled'; + } + + /** + * Replace the disabled text with [dsBtnDisabled] in the template + */ + function replaceDisabledText(text: string ): string { + const hasBrackets = text.includes('[') && text.includes(']'); + const newDisabledText = hasBrackets ? 'dsBtnDisabled' : '[dsBtnDisabled]="true"'; + return text.replace('disabled', newDisabledText); + } + + function inputIsChildOfButton(node: any): boolean { + return (node.parent?.tagName === 'button' || node.parent?.name === 'button'); + } + + function reportAndFix(node: TmplAstBoundAttribute | TmplAstTextAttribute) { + if (!inputIsChildOfButton(node) || isOtherAttributeDisabled(node)) { + return; + } + + const sourceSpan = node.sourceSpan; + context.report({ + messageId: Message.USE_DSBTN_DISABLED, + loc: parserServices.convertNodeSourceSpanToLoc(sourceSpan), + fix(fixer) { + const templateText = sourceSpan.start.file.content; + const disabledText = templateText.slice(sourceSpan.start.offset, sourceSpan.end.offset); + const newText = replaceDisabledText(disabledText); + return fixer.replaceTextRange([sourceSpan.start.offset, sourceSpan.end.offset], newText); + }, + }); + } + + return { + 'BoundAttribute[name="disabled"]'(node: TmplAstBoundAttribute) { + reportAndFix(node); + }, + 'TextAttribute[name="disabled"]'(node: TmplAstTextAttribute) { + reportAndFix(node); + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'should use [dsBtnDisabled] in HTML templates', + code: ` + + `, + }, + { + name: 'disabled attribute is still valid on non-button elements', + code: ` + + `, + }, + { + name: '[disabled] attribute is still valid on non-button elements', + code: ` + + `, + }, + { + name: 'angular dynamic attributes that use disabled are still valid', + code: ` + + `, + }, + ], + invalid: [ + { + name: 'should not use disabled attribute in HTML templates', + code: ` + + `, + errors: [{ messageId: Message.USE_DSBTN_DISABLED }], + output: ` + + `, + }, + { + name: 'should not use [disabled] attribute in HTML templates', + code: ` + + `, + errors: [{ messageId: Message.USE_DSBTN_DISABLED }], + output: ` + + `, + }, + ], +} as NamedTests; + +export default rule; diff --git a/lint/src/rules/html/themed-component-usages.ts b/lint/src/rules/html/themed-component-usages.ts new file mode 100644 index 00000000000..e907285dbca --- /dev/null +++ b/lint/src/rules/html/themed-component-usages.ts @@ -0,0 +1,189 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler'; +import { TemplateParserServices } from '@angular-eslint/utils'; +import { ESLintUtils } from '@typescript-eslint/utils'; +import { RuleContext } from '@typescript-eslint/utils/ts-eslint'; + +import { fixture } from '../../../test/fixture'; +import { + DSpaceESLintRuleInfo, + NamedTests, +} from '../../util/structure'; +import { + DISALLOWED_THEME_SELECTORS, + fixSelectors, +} from '../../util/theme-support'; +import { + getFilename, + getSourceCode, +} from '../../util/typescript'; + +export enum Message { + WRONG_SELECTOR = 'mustUseThemedWrapperSelector', +} + +export const info = { + name: 'themed-component-usages', + meta: { + docs: { + description: `Themeable components should be used via the selector of their \`ThemedComponent\` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple. + `, + }, + type: 'problem', + fixable: 'code', + schema: [], + messages: { + [Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper\'s selector', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: RuleContext) { + if (getFilename(context).includes('.spec.ts')) { + // skip inline templates in unit tests + return {}; + } + + const parserServices = getSourceCode(context).parserServices as TemplateParserServices; + + return { + [`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: TmplAstElement) { + const { startSourceSpan, endSourceSpan } = node; + const openStart = startSourceSpan.start.offset as number; + + context.report({ + messageId: Message.WRONG_SELECTOR, + loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan), + fix(fixer) { + const oldSelector = node.name; + const newSelector = fixSelectors(oldSelector); + + const ops = [ + fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector), + ]; + + // make sure we don't mangle self-closing tags + if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) { + const closeStart = endSourceSpan.start.offset as number; + const closeEnd = endSourceSpan.end.offset as number; + + ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector)); + } + + return ops; + }, + }); + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'use no-prefix selectors in HTML templates', + code: ` + + + + `, + }, + { + name: 'use no-prefix selectors in TypeScript templates', + code: ` +@Component({ + template: '' +}) +class Test { +} + `, + }, + { + name: 'use no-prefix selectors in TypeScript test templates', + filename: fixture('src/test.spec.ts'), + code: ` +@Component({ + template: '' +}) +class Test { +} + `, + }, + { + name: 'base selectors are also allowed in TypeScript test templates', + filename: fixture('src/test.spec.ts'), + code: ` +@Component({ + template: '' +}) +class Test { +} + `, + }, + ], + invalid: [ + { + name: 'themed override selectors are not allowed in HTML templates', + code: ` + + + + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` + + + + `, + }, + { + name: 'base selectors are not allowed in HTML templates', + code: ` + + + + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` + + + + `, + }, + ], +} as NamedTests; + +export default rule; diff --git a/lint/src/rules/ts/index.ts b/lint/src/rules/ts/index.ts new file mode 100644 index 00000000000..a7fdfe41efe --- /dev/null +++ b/lint/src/rules/ts/index.ts @@ -0,0 +1,25 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + bundle, + RuleExports, +} from '../../util/structure'; +/* eslint-disable import/no-namespace */ +import * as themedComponentClasses from './themed-component-classes'; +import * as themedComponentSelectors from './themed-component-selectors'; +import * as themedComponentUsages from './themed-component-usages'; + +const index = [ + themedComponentClasses, + themedComponentSelectors, + themedComponentUsages, +] as unknown as RuleExports[]; + +export = { + ...bundle('dspace-angular-ts', 'TypeScript', index), +}; diff --git a/lint/src/rules/ts/themed-component-classes.ts b/lint/src/rules/ts/themed-component-classes.ts new file mode 100644 index 00000000000..527655adfa4 --- /dev/null +++ b/lint/src/rules/ts/themed-component-classes.ts @@ -0,0 +1,382 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + ESLintUtils, + TSESTree, +} from '@typescript-eslint/utils'; +import { RuleContext } from '@typescript-eslint/utils/ts-eslint'; + +import { fixture } from '../../../test/fixture'; +import { + getComponentImportNode, + getComponentInitializer, + getComponentStandaloneNode, +} from '../../util/angular'; +import { appendObjectProperties } from '../../util/fix'; +import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + getBaseComponentClassName, + inThemedComponentOverrideFile, + isThemeableComponent, + isThemedComponentWrapper, +} from '../../util/theme-support'; +import { getFilename } from '../../util/typescript'; + +export enum Message { + NOT_STANDALONE = 'mustBeStandalone', + NOT_STANDALONE_IMPORTS_BASE = 'mustBeStandaloneAndImportBase', + WRAPPER_IMPORTS_BASE = 'wrapperShouldImportBase', +} + +export const info = { + name: 'themed-component-classes', + meta: { + docs: { + description: `Formatting rules for themeable component classes + +- All themeable components must be standalone. +- The base component must always be imported in the \`ThemedComponent\` wrapper. This ensures that it is always sufficient to import just the wrapper whenever we use the component. + `, + }, + type: 'problem', + fixable: 'code', + schema: [], + messages: { + [Message.NOT_STANDALONE]: 'Themeable components must be standalone', + [Message.NOT_STANDALONE_IMPORTS_BASE]: 'Themeable component wrapper classes must be standalone and import the base class', + [Message.WRAPPER_IMPORTS_BASE]: 'Themed component wrapper classes must only import the base class', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: RuleContext) { + const filename = getFilename(context); + + if (filename.endsWith('.spec.ts')) { + return {}; + } + + function enforceStandalone(decoratorNode: TSESTree.Decorator, withBaseImport = false) { + const standaloneNode = getComponentStandaloneNode(decoratorNode); + + if (standaloneNode === undefined) { + // We may need to add these properties in one go + if (!withBaseImport) { + context.report({ + messageId: Message.NOT_STANDALONE, + node: decoratorNode, + fix(fixer) { + const initializer = getComponentInitializer(decoratorNode); + return appendObjectProperties(context, fixer, initializer, ['standalone: true']); + }, + }); + } + } else if (!standaloneNode.value) { + context.report({ + messageId: Message.NOT_STANDALONE, + node: standaloneNode, + fix(fixer) { + return fixer.replaceText(standaloneNode, 'true'); + }, + }); + } + + if (withBaseImport) { + const baseClass = getBaseComponentClassName(decoratorNode); + + if (baseClass === undefined) { + return; + } + + const importsNode = getComponentImportNode(decoratorNode); + + if (importsNode === undefined) { + if (standaloneNode === undefined) { + context.report({ + messageId: Message.NOT_STANDALONE_IMPORTS_BASE, + node: decoratorNode, + fix(fixer) { + const initializer = getComponentInitializer(decoratorNode); + return appendObjectProperties(context, fixer, initializer, ['standalone: true', `imports: [${baseClass}]`]); + }, + }); + } else { + context.report({ + messageId: Message.WRAPPER_IMPORTS_BASE, + node: decoratorNode, + fix(fixer) { + const initializer = getComponentInitializer(decoratorNode); + return appendObjectProperties(context, fixer, initializer, [`imports: [${baseClass}]`]); + }, + }); + } + } else { + // If we have an imports node, standalone: true will be enforced by another rule + + const imports = importsNode.elements.map(e => (e as TSESTree.Identifier).name); + + if (!imports.includes(baseClass) || imports.length > 1) { + // The wrapper should _only_ import the base component + context.report({ + messageId: Message.WRAPPER_IMPORTS_BASE, + node: importsNode, + fix(fixer) { + // todo: this may leave unused imports, but that's better than mangling things + return fixer.replaceText(importsNode, `[${baseClass}]`); + }, + }); + } + } + } + } + + return { + 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) { + const classNode = node.parent as TSESTree.ClassDeclaration; + const className = classNode.id?.name; + + if (className === undefined) { + return; + } + + if (isThemedComponentWrapper(node)) { + enforceStandalone(node, true); + } else if (inThemedComponentOverrideFile(filename)) { + enforceStandalone(node); + } else if (isThemeableComponent(className)) { + enforceStandalone(node); + } + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'Regular non-themeable component', + code: ` +@Component({ + selector: 'ds-something', + standalone: true, +}) +class Something { +} + `, + }, + { + name: 'Base component', + code: ` +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableTomponent { +} + `, + }, + { + name: 'Wrapper component', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + TestThemeableComponent, + ], +}) +class ThemedTestThemeableTomponent extends ThemedComponent { +} + `, + }, + { + name: 'Override component', + filename: fixture('src/themes/test/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} + `, + }, + ], + invalid: [ + { + name: 'Base component must be standalone', + code: ` +@Component({ + selector: 'ds-base-test-themable', +}) +class TestThemeableComponent { +} + `, + errors:[ + { + messageId: Message.NOT_STANDALONE, + }, + ], + output: ` +@Component({ + selector: 'ds-base-test-themable', + standalone: true, +}) +class TestThemeableComponent { +} + `, + }, + { + name: 'Wrapper component must be standalone and import base component', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-test-themable', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.NOT_STANDALONE_IMPORTS_BASE, + }, + ], + output: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + + { + name: 'Wrapper component must import base component (array present but empty)', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.WRAPPER_IMPORTS_BASE, + }, + ], + output: ` +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + { + name: 'Wrapper component must import base component (array is wrong)', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.WRAPPER_IMPORTS_BASE, + }, + ], + output: ` +import { SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, { + name: 'Wrapper component must import base component (array is wrong)', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [ + SomethingElse, + ], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors:[ + { + messageId: Message.WRAPPER_IMPORTS_BASE, + }, + ], + output: ` +import { Something, SomethingElse } from './somewhere-else'; + +@Component({ + selector: 'ds-test-themable', + standalone: true, + imports: [TestThemeableComponent], +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + { + name: 'Override component must be standalone', + filename: fixture('src/themes/test/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-themed-test-themable', +}) +class Override extends BaseComponent { +} + `, + errors:[ + { + messageId: Message.NOT_STANDALONE, + }, + ], + output: ` +@Component({ + selector: 'ds-themed-test-themable', + standalone: true, +}) +class Override extends BaseComponent { +} + `, + }, + ], +}; diff --git a/lint/src/rules/ts/themed-component-selectors.ts b/lint/src/rules/ts/themed-component-selectors.ts new file mode 100644 index 00000000000..c27fd66d662 --- /dev/null +++ b/lint/src/rules/ts/themed-component-selectors.ts @@ -0,0 +1,257 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + ESLintUtils, + TSESTree, +} from '@typescript-eslint/utils'; +import { RuleContext } from '@typescript-eslint/utils/ts-eslint'; + +import { fixture } from '../../../test/fixture'; +import { getComponentSelectorNode } from '../../util/angular'; +import { stringLiteral } from '../../util/misc'; +import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + inThemedComponentOverrideFile, + isThemeableComponent, + isThemedComponentWrapper, +} from '../../util/theme-support'; +import { getFilename } from '../../util/typescript'; + +export enum Message { + BASE = 'wrongSelectorUnthemedComponent', + WRAPPER = 'wrongSelectorThemedComponentWrapper', + THEMED = 'wrongSelectorThemedComponentOverride', +} + +export const info = { + name: 'themed-component-selectors', + meta: { + docs: { + description: `Themeable component selectors should follow the DSpace convention + +Each themeable component is comprised of a base component, a wrapper component and any number of themed components +- Base components should have a selector starting with \`ds-base-\` +- Themed components should have a selector starting with \`ds-themed-\` +- Wrapper components should have a selector starting with \`ds-\`, but not \`ds-base-\` or \`ds-themed-\` + - This is the regular DSpace selector prefix + - **When making a regular component themeable, its selector prefix should be changed to \`ds-base-\`, and the new wrapper's component should reuse the previous selector** + +Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source. + `, + }, + type: 'problem', + schema: [], + fixable: 'code', + messages: { + [Message.BASE]: 'Unthemed version of themeable component should have a selector starting with \'ds-base-\'', + [Message.WRAPPER]: 'Themed component wrapper of themeable component shouldn\'t have a selector starting with \'ds-themed-\'', + [Message.THEMED]: 'Theme override of themeable component should have a selector starting with \'ds-themed-\'', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: RuleContext) { + const filename = getFilename(context); + + if (filename.endsWith('.spec.ts')) { + return {}; + } + + function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) { + if (selectorNode?.value.startsWith('ds-themed-')) { + context.report({ + messageId: Message.WRAPPER, + node: selectorNode, + fix(fixer) { + return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-'))); + }, + }); + } + } + + function enforceBaseSelector(selectorNode: TSESTree.StringLiteral) { + if (!selectorNode?.value.startsWith('ds-base-')) { + context.report({ + messageId: Message.BASE, + node: selectorNode, + fix(fixer) { + return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-'))); + }, + }); + } + } + + function enforceThemedSelector(selectorNode: TSESTree.StringLiteral) { + if (!selectorNode?.value.startsWith('ds-themed-')) { + context.report({ + messageId: Message.THEMED, + node: selectorNode, + fix(fixer) { + return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-'))); + }, + }); + } + } + + return { + 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) { + const selectorNode = getComponentSelectorNode(node); + + if (selectorNode === undefined) { + return; + } + + const selector = selectorNode?.value; + const classNode = node.parent as TSESTree.ClassDeclaration; + const className = classNode.id?.name; + + if (selector === undefined || className === undefined) { + return; + } + + if (isThemedComponentWrapper(node)) { + enforceWrapperSelector(selectorNode); + } else if (inThemedComponentOverrideFile(filename)) { + enforceThemedSelector(selectorNode); + } else if (isThemeableComponent(className)) { + enforceBaseSelector(selectorNode); + } + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'Regular non-themeable component selector', + code: ` +@Component({ + selector: 'ds-something', +}) +class Something { +} + `, + }, + { + name: 'Themeable component selector should replace the original version, unthemed version should be changed to ds-base-', + code: ` +@Component({ + selector: 'ds-base-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something', +}) +class ThemedSomething extends ThemedComponent { +} + +@Component({ + selector: 'ds-themed-something', +}) +class OverrideSomething extends Something { +} + `, + }, + { + name: 'Other themed component wrappers should not interfere', + code: ` +@Component({ + selector: 'ds-something', +}) +class Something { +} + +@Component({ + selector: 'ds-something-else', +}) +class ThemedSomethingElse extends ThemedComponent { +} + `, + }, + ], + invalid: [ + { + name: 'Wrong selector for base component', + filename: fixture('src/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-something', +}) +class TestThemeableComponent { +} + `, + errors: [ + { + messageId: Message.BASE, + }, + ], + output: ` +@Component({ + selector: 'ds-base-something', +}) +class TestThemeableComponent { +} + `, + }, + { + name: 'Wrong selector for wrapper component', + filename: fixture('src/app/test/themed-test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-themed-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + errors: [ + { + messageId: Message.WRAPPER, + }, + ], + output: ` +@Component({ + selector: 'ds-something', +}) +class ThemedTestThemeableComponent extends ThemedComponent { +} + `, + }, + { + name: 'Wrong selector for theme override', + filename: fixture('src/themes/test/app/test/test-themeable.component.ts'), + code: ` +@Component({ + selector: 'ds-something', +}) +class TestThememeableComponent extends BaseComponent { +} + `, + errors: [ + { + messageId: Message.THEMED, + }, + ], + output: ` +@Component({ + selector: 'ds-themed-something', +}) +class TestThememeableComponent extends BaseComponent { +} + `, + }, + ], +}; + +export default rule; diff --git a/lint/src/rules/ts/themed-component-usages.ts b/lint/src/rules/ts/themed-component-usages.ts new file mode 100644 index 00000000000..83fe6f8ea89 --- /dev/null +++ b/lint/src/rules/ts/themed-component-usages.ts @@ -0,0 +1,502 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + ESLintUtils, + TSESTree, +} from '@typescript-eslint/utils'; +import { RuleContext } from '@typescript-eslint/utils/ts-eslint'; + +import { fixture } from '../../../test/fixture'; +import { + removeWithCommas, + replaceOrRemoveArrayIdentifier, +} from '../../util/fix'; +import { DSpaceESLintRuleInfo } from '../../util/structure'; +import { + allThemeableComponents, + DISALLOWED_THEME_SELECTORS, + fixSelectors, + getThemeableComponentByBaseClass, + isAllowedUnthemedUsage, +} from '../../util/theme-support'; +import { + findImportSpecifier, + findUsages, + findUsagesByName, + getFilename, + relativePath, +} from '../../util/typescript'; + +export enum Message { + WRONG_CLASS = 'mustUseThemedWrapperClass', + WRONG_IMPORT = 'mustImportThemedWrapper', + WRONG_SELECTOR = 'mustUseThemedWrapperSelector', + BASE_IN_MODULE = 'baseComponentNotNeededInModule', +} + +export const info = { + name: 'themed-component-usages', + meta: { + docs: { + description: `Themeable components should be used via their \`ThemedComponent\` wrapper class + +This ensures that custom themes can correctly override _all_ instances of this component. +There are a few exceptions where the base class can still be used: +- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place) +- Angular modules (except for routing modules) +- Angular \`@ViewChild\` decorators +- Type annotations + `, + }, + type: 'problem', + schema: [], + fixable: 'code', + messages: { + [Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper', + [Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper', + [Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper', + [Message.BASE_IN_MODULE]: 'Base themeable components shouldn\'t be declared in modules', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: RuleContext) { + const filename = getFilename(context); + + function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) { + if (isAllowedUnthemedUsage(node)) { + return; + } + + const entry = getThemeableComponentByBaseClass(node.name); + + if (entry === undefined) { + // this should never happen + throw new Error(`No such themeable component in registry: '${node.name}'`); + } + + context.report({ + messageId: Message.WRONG_CLASS, + node: node, + fix(fixer) { + if (node.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) { + return replaceOrRemoveArrayIdentifier(context, fixer, node, entry.wrapperClass); + } else { + return fixer.replaceText(node, entry.wrapperClass); + } + }, + }); + } + + function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) { + context.report({ + node, + messageId: Message.WRONG_SELECTOR, + fix(fixer){ + const newSelector = fixSelectors(node.raw); + return fixer.replaceText(node, newSelector); + }, + }); + } + + function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) { + const allUsages = findUsages(context, specifierNode.local); + const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage)); + + if (badUsages.length === 0) { + return; + } + + const importedNode = specifierNode.imported; + const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration; + + const entry = getThemeableComponentByBaseClass(importedNode.name); + if (entry === undefined) { + // this should never happen + throw new Error(`No such themeable component in registry: '${importedNode.name}'`); + } + + context.report({ + messageId: Message.WRONG_IMPORT, + node: importedNode, + fix(fixer) { + const ops = []; + + const wrapperImport = findImportSpecifier(context, entry.wrapperClass); + + if (findUsagesByName(context, entry.wrapperClass).length === 0) { + // Wrapper is not present in this file, safe to add import + + const newImportLine = `import { ${entry.wrapperClass} } from '${relativePath(filename, entry.wrapperPath)}';`; + + if (declarationNode.specifiers.length === 1) { + if (allUsages.length === badUsages.length) { + ops.push(fixer.replaceText(declarationNode, newImportLine)); + } else if (wrapperImport === undefined) { + ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine)); + } + } else { + ops.push(...removeWithCommas(context, fixer, specifierNode)); + if (wrapperImport === undefined) { + ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine)); + } + } + } else { + // Wrapper already present in the file, remove import instead + + if (allUsages.length === badUsages.length) { + if (declarationNode.specifiers.length === 1) { + // Make sure we remove the newline as well + ops.push(fixer.removeRange([declarationNode.range[0], declarationNode.range[1] + 1])); + } else { + ops.push(...removeWithCommas(context, fixer, specifierNode)); + } + } + } + + return ops; + }, + }); + } + + // ignore tests and non-routing modules + if (filename.endsWith('.spec.ts')) { + return { + [`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, + }; + } else if (filename.endsWith('.cy.ts')) { + return { + [`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, + }; + } else if ( + filename.match(/(?!src\/themes\/).*(?!routing).module.ts$/) + || filename.match(/themed-.+\.component\.ts$/) + ) { + // do nothing + return {}; + } else { + return allThemeableComponents().reduce( + (rules, entry) => { + return { + ...rules, + [`:not(:matches(ClassDeclaration, ImportSpecifier)) > Identifier[name = "${entry.baseClass}"]`]: handleUnthemedUsagesInTypescript, + [`ImportSpecifier[imported.name = "${entry.baseClass}"]`]: handleUnthemedImportsInTypescript, + }; + }, {}, + ); + } + + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'allow wrapper class usages', + code: ` +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: ChipsComponent, +} + `, + }, + { + name: 'allow base class in class declaration', + code: ` +export class TestThemeableComponent { +} + `, + }, + { + name: 'allow inheriting from base class', + code: ` +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class ThemedAdminSidebarComponent extends ThemedComponent { +} + `, + }, + { + name: 'allow base class in ViewChild', + code: ` +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +export class Something { + @ViewChild(TestThemeableComponent) test: TestThemeableComponent; +} + `, + }, + { + name: 'allow wrapper selectors in test queries', + filename: fixture('src/app/test/test.component.spec.ts'), + code: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + { + name: 'allow wrapper selectors in cypress queries', + filename: fixture('src/app/test/test.component.cy.ts'), + code: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + ], + invalid: [ + { + name: 'disallow direct usages of base class', + code: ` +import { TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, +} + `, + }, + { + name: 'disallow direct usages of base class, keep other imports', + code: ` +import { Something, TestThemeableComponent } from './app/test/test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: TestThemeableComponent, + b: TestComponent, + c: Something, +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { Something } from './app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component'; +import { TestComponent } from './app/test/test.component'; + +const config = { + a: ThemedTestThemeableComponent, + b: TestComponent, + c: Something, +} + `, + }, + { + name: 'handle array replacements correctly', + code: ` +const DECLARATIONS = [ + Something, + TestThemeableComponent, + Something, + ThemedTestThemeableComponent, +]; + `, + errors: [ + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +const DECLARATIONS = [ + Something, + Something, + ThemedTestThemeableComponent, +]; + `, + }, + { + name: 'disallow override selector in test queries', + filename: fixture('src/app/test/test.component.spec.ts'), + code: ` +By.css('ds-themed-themeable'); +By.css('#test > ds-themed-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + { + name: 'disallow base selector in test queries', + filename: fixture('src/app/test/test.component.spec.ts'), + code: ` +By.css('ds-base-themeable'); +By.css('#test > ds-base-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +By.css('ds-themeable'); +By.css('#test > ds-themeable > #nest'); + `, + }, + { + name: 'disallow override selector in cypress queries', + filename: fixture('src/app/test/test.component.cy.ts'), + code: ` +cy.get('ds-themed-themeable'); +cy.get('#test > ds-themed-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); + `, + }, + { + name: 'disallow base selector in cypress queries', + filename: fixture('src/app/test/test.component.cy.ts'), + code: ` +cy.get('ds-base-themeable'); +cy.get('#test > ds-base-themeable > #nest'); + `, + errors: [ + { + messageId: Message.WRONG_SELECTOR, + }, + { + messageId: Message.WRONG_SELECTOR, + }, + ], + output: ` +cy.get('ds-themeable'); +cy.get('#test > ds-themeable > #nest'); + `, + }, + { + name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed', + filename: fixture('src/themes/test/app/test/other-themeable.component.ts'), + code: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent], +}) +export class UsageComponent { +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} + `, + }, + { + name: 'edge case edge case: both are imported, only wrapper is retained', + filename: fixture('src/themes/test/app/test/other-themeable.component.ts'), + code: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { TestThemeableComponent } from '../../../../app/test/test-themeable.component'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [TestThemeableComponent, ThemedTestThemeableComponent], +}) +export class UsageComponent { +} + `, + errors: [ + { + messageId: Message.WRONG_IMPORT, + }, + { + messageId: Message.WRONG_CLASS, + }, + ], + output: ` +import { Component } from '@angular/core'; + +import { Context } from './app/core/shared/context.model'; +import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component'; + +@Component({ + standalone: true, + imports: [ThemedTestThemeableComponent], +}) +export class UsageComponent { +} + `, + }, + ], +}; + +export default rule; diff --git a/lint/src/util/angular.ts b/lint/src/util/angular.ts new file mode 100644 index 00000000000..70ee903fb81 --- /dev/null +++ b/lint/src/util/angular.ts @@ -0,0 +1,83 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TSESTree } from '@typescript-eslint/utils'; + +import { getObjectPropertyNodeByName } from './typescript'; + +export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined { + const property = getComponentInitializerNodeByName(componentDecoratorNode, 'selector'); + + if (property !== undefined) { + // todo: support template literals as well + if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'string') { + return property as TSESTree.StringLiteral; + } + } + + return undefined; +} + +export function getComponentStandaloneNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.BooleanLiteral | undefined { + const property = getComponentInitializerNodeByName(componentDecoratorNode, 'standalone'); + + if (property !== undefined) { + if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'boolean') { + return property as TSESTree.BooleanLiteral; + } + } + + return undefined; +} +export function getComponentImportNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.ArrayExpression | undefined { + const property = getComponentInitializerNodeByName(componentDecoratorNode, 'imports'); + + if (property !== undefined) { + if (property.type === TSESTree.AST_NODE_TYPES.ArrayExpression) { + return property as TSESTree.ArrayExpression; + } + } + + return undefined; +} + +export function getComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return undefined; + } + + if (decoratorNode.parent.id?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return undefined; + } + + return decoratorNode.parent.id.name; +} + +export function getComponentSuperClassName(decoratorNode: TSESTree.Decorator): string | undefined { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return undefined; + } + + if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return undefined; + } + + return decoratorNode.parent.superClass.name; +} + +export function getComponentInitializer(componentDecoratorNode: TSESTree.Decorator): TSESTree.ObjectExpression { + return (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression; +} + +export function getComponentInitializerNodeByName(componentDecoratorNode: TSESTree.Decorator, name: string): TSESTree.Node | undefined { + const initializer = getComponentInitializer(componentDecoratorNode); + return getObjectPropertyNodeByName(initializer, name); +} + +export function isPartOfViewChild(node: TSESTree.Identifier): boolean { + return (node.parent as any)?.callee?.name === 'ViewChild'; +} diff --git a/lint/src/util/fix.ts b/lint/src/util/fix.ts new file mode 100644 index 00000000000..10408cc316c --- /dev/null +++ b/lint/src/util/fix.ts @@ -0,0 +1,125 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TSESTree } from '@typescript-eslint/utils'; +import { + RuleContext, + RuleFix, + RuleFixer, +} from '@typescript-eslint/utils/ts-eslint'; + +import { getSourceCode } from './typescript'; + + + +export function appendObjectProperties(context: RuleContext, fixer: RuleFixer, objectNode: TSESTree.ObjectExpression, properties: string[]): RuleFix { + // todo: may not handle empty objects too well + const lastProperty = objectNode.properties[objectNode.properties.length - 1]; + const source = getSourceCode(context); + const nextToken = source.getTokenAfter(lastProperty); + + // todo: newline & indentation are hardcoded for @Component({}) + // todo: we're assuming that we need trailing commas, what if we don't? + const newPart = '\n' + properties.map(p => ` ${p},`).join('\n'); + + if (nextToken !== null && nextToken.value === ',') { + return fixer.insertTextAfter(nextToken, newPart); + } else { + return fixer.insertTextAfter(lastProperty, ',' + newPart); + } +} + +export function appendArrayElement(context: RuleContext, fixer: RuleFixer, arrayNode: TSESTree.ArrayExpression, value: string): RuleFix { + const source = getSourceCode(context); + + if (arrayNode.elements.length === 0) { + // This is the first element + const openArray = source.getTokenByRangeStart(arrayNode.range[0]); + + if (openArray == null) { + throw new Error('Unexpected null token for opening square bracket'); + } + + // safe to assume the list is single-line + return fixer.insertTextAfter(openArray, `${value}`); + } else { + const lastElement = arrayNode.elements[arrayNode.elements.length - 1]; + + if (lastElement == null) { + throw new Error('Unexpected null node in array'); + } + + const nextToken = source.getTokenAfter(lastElement); + + // todo: we don't know if the list is chopped or not, so we can't make any assumptions -- may produce output that will be flagged by other rules on the next run! + // todo: we're assuming that we need trailing commas, what if we don't? + if (nextToken !== null && nextToken.value === ',') { + return fixer.insertTextAfter(nextToken, ` ${value},`); + } else { + return fixer.insertTextAfter(lastElement, `, ${value},`); + } + } + +} + +export function isLast(elementNode: TSESTree.Node): boolean { + if (!elementNode.parent) { + return false; + } + + let siblingNodes: (TSESTree.Node | null)[] = [null]; + if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) { + siblingNodes = elementNode.parent.elements; + } else if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) { + siblingNodes = elementNode.parent.specifiers; + } + + return elementNode === siblingNodes[siblingNodes.length - 1]; +} + +export function removeWithCommas(context: RuleContext, fixer: RuleFixer, elementNode: TSESTree.Node): RuleFix[] { + const ops = []; + + const source = getSourceCode(context); + let nextToken = source.getTokenAfter(elementNode); + let prevToken = source.getTokenBefore(elementNode); + + if (nextToken !== null && prevToken !== null) { + if (nextToken.value === ',') { + nextToken = source.getTokenAfter(nextToken); + if (nextToken !== null) { + ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]])); + } + } + if (isLast(elementNode) && prevToken.value === ',') { + prevToken = source.getTokenBefore(prevToken); + if (prevToken !== null) { + ops.push(fixer.removeRange([prevToken.range[1], elementNode.range[1]])); + } + } + } else if (nextToken !== null) { + ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]])); + } + + return ops; +} + +export function replaceOrRemoveArrayIdentifier(context: RuleContext, fixer: RuleFixer, identifierNode: TSESTree.Identifier, newValue: string): RuleFix[] { + if (identifierNode.parent.type !== TSESTree.AST_NODE_TYPES.ArrayExpression) { + throw new Error('Parent node is not an array expression!'); + } + + const array = identifierNode.parent as TSESTree.ArrayExpression; + + for (const element of array.elements) { + if (element !== null && element.type === TSESTree.AST_NODE_TYPES.Identifier && element.name === newValue) { + return removeWithCommas(context, fixer, identifierNode); + } + } + + return [fixer.replaceText(identifierNode, newValue)]; +} diff --git a/lint/src/util/misc.ts b/lint/src/util/misc.ts new file mode 100644 index 00000000000..49cb60124e8 --- /dev/null +++ b/lint/src/util/misc.ts @@ -0,0 +1,28 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +export function match(rangeA: number[], rangeB: number[]) { + return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1]; +} + + +export function stringLiteral(value: string): string { + return `'${value}'`; +} + +/** + * Transform Windows-style paths into Unix-style paths + */ +export function toUnixStylePath(path: string): string { + // note: we're assuming that none of the directory/file names contain '\' or '/' characters. + // using these characters in paths is very bad practice in general, so this should be a safe assumption. + if (path.includes('\\')) { + return path.replace(/^[A-Z]:\\/, '/').replaceAll('\\', '/'); + } + return path; +} diff --git a/lint/src/util/structure.ts b/lint/src/util/structure.ts new file mode 100644 index 00000000000..2e3aebd9ab4 --- /dev/null +++ b/lint/src/util/structure.ts @@ -0,0 +1,61 @@ +/** + * 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 { + InvalidTestCase, + RuleMetaData, + RuleModule, + ValidTestCase, +} from '@typescript-eslint/utils/ts-eslint'; +import { EnumType } from 'typescript'; + +export type Meta = RuleMetaData; +export type Valid = ValidTestCase; +export type Invalid = InvalidTestCase; + +export interface DSpaceESLintRuleInfo { + name: string; + meta: Meta, + defaultOptions: unknown[], +} + +export interface NamedTests { + plugin: string; + valid: Valid[]; + invalid: Invalid[]; +} + +export interface RuleExports { + Message: EnumType, + info: DSpaceESLintRuleInfo, + rule: RuleModule, + tests: NamedTests, + default: unknown, +} + +export interface PluginExports { + name: string, + language: string, + rules: Record, + index: RuleExports[], +} + +export function bundle( + name: string, + language: string, + index: RuleExports[], +): PluginExports { + return index.reduce((o: PluginExports, i: RuleExports) => { + o.rules[i.info.name] = i.rule; + return o; + }, { + name, + language, + rules: {}, + index, + }); +} diff --git a/lint/src/util/templates/index.ejs b/lint/src/util/templates/index.ejs new file mode 100644 index 00000000000..d959f292910 --- /dev/null +++ b/lint/src/util/templates/index.ejs @@ -0,0 +1,5 @@ +[DSpace ESLint plugins](../../../lint/README.md) > <%= plugin.language %> rules +_______ +<% rules.forEach(rule => { %> +- [`<%= plugin.name %>/<%= rule.name %>`](./rules/<%= rule.name %>.md)<% if (rule.meta?.docs?.description) {%>: <%= rule.meta.docs.description.split('\n')[0].trim() -%><% }-%> +<% }) %> diff --git a/lint/src/util/templates/rule.ejs b/lint/src/util/templates/rule.ejs new file mode 100644 index 00000000000..b39d193cc18 --- /dev/null +++ b/lint/src/util/templates/rule.ejs @@ -0,0 +1,48 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [<%= plugin.language %> rules](../index.md) > `<%= plugin.name %>/<%= rule.name %>` +_______ + +<%- rule.meta.docs?.description %> + +_______ + +[Source code](../../../../lint/src/rules/<%- plugin.name.replace('dspace-angular-', '') %>/<%- rule.name %>.ts) + +### Examples + +<% if (tests.valid) {%> +#### Valid code + <% tests.valid.forEach(test => { %> +##### <%= test.name !== undefined ? test.name : 'UNNAMED' %> + <% if (test.filename) { %> +Filename: `<%- test.filename %>` + <% } %> +```<%- plugin.language.toLowerCase() %> +<%- test.code.trim() %> +``` + <% }) %> +<% } %> + +<% if (tests.invalid) {%> +#### Invalid code <%= rule.meta.fixable ? ' & automatic fixes' : '' %> + <% tests.invalid.forEach(test => { %> +##### <%= test.name !== undefined ? test.name : 'UNNAMED' %> + <% if (test.filename) { %> +Filename: `<%- test.filename %>` + <% } %> +```<%- plugin.language.toLowerCase() %> +<%- test.code.trim() %> +``` +Will produce the following error(s): +``` +<% for (const error of test.errors) { -%> +<%- rule.meta.messages[error.messageId] %> +<% } -%> +``` + <% if (test.output) { %> +Result of `yarn lint --fix`: +```<%- plugin.language.toLowerCase() %> +<%- test.output.trim() %> +``` + <% } %> + <% }) %> +<% } %> diff --git a/lint/src/util/theme-support.ts b/lint/src/util/theme-support.ts new file mode 100644 index 00000000000..64644145fae --- /dev/null +++ b/lint/src/util/theme-support.ts @@ -0,0 +1,265 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TSESTree } from '@typescript-eslint/utils'; +import { readFileSync } from 'fs'; +import { basename } from 'path'; +import ts, { Identifier } from 'typescript'; + +import { + getComponentClassName, + isPartOfViewChild, +} from './angular'; +import { + isPartOfClassDeclaration, + isPartOfTypeExpression, +} from './typescript'; + +/** + * Couples a themeable Component to its ThemedComponent wrapper + */ +export interface ThemeableComponentRegistryEntry { + basePath: string; + baseFileName: string, + baseClass: string; + + wrapperPath: string; + wrapperFileName: string, + wrapperClass: string; +} + +function isAngularComponentDecorator(node: ts.Node) { + if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) { + const decorator = node as ts.Decorator; + + if (decorator.expression.kind === ts.SyntaxKind.CallExpression) { + const method = decorator.expression as ts.CallExpression; + + if (method.expression.kind === ts.SyntaxKind.Identifier) { + return (method.expression as Identifier).text === 'Component'; + } + } + } + + return false; +} + +function findImportDeclaration(source: ts.SourceFile, identifierName: string): ts.ImportDeclaration | undefined { + return ts.forEachChild(source, (topNode: ts.Node) => { + if (topNode.kind === ts.SyntaxKind.ImportDeclaration) { + const importDeclaration = topNode as ts.ImportDeclaration; + + if (importDeclaration.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) { + const namedImports = importDeclaration.importClause?.namedBindings as ts.NamedImports; + + for (const element of namedImports.elements) { + if (element.name.text === identifierName) { + return importDeclaration; + } + } + } + } + + return undefined; + }); +} + +/** + * Listing of all themeable Components + */ +class ThemeableComponentRegistry { + public readonly entries: Set; + public readonly byBaseClass: Map; + public readonly byWrapperClass: Map; + public readonly byBasePath: Map; + public readonly byWrapperPath: Map; + + constructor() { + this.entries = new Set(); + this.byBaseClass = new Map(); + this.byWrapperClass = new Map(); + this.byBasePath = new Map(); + this.byWrapperPath = new Map(); + } + + public initialize(prefix = '') { + if (this.entries.size > 0) { + return; + } + + function registerWrapper(path: string) { + const source = getSource(path); + + function traverse(node: ts.Node) { + if (node.parent !== undefined && isAngularComponentDecorator(node)) { + const classNode = node.parent as ts.ClassDeclaration; + + if (classNode.name === undefined || classNode.heritageClauses === undefined) { + return; + } + + const wrapperClass = classNode.name?.escapedText as string; + + for (const heritageClause of classNode.heritageClauses) { + for (const type of heritageClause.types) { + if ((type as any).expression.escapedText === 'ThemedComponent') { + if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) { + continue; + } + + const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode; + const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText; + + if (baseClass === undefined) { + continue; + } + + const importDeclaration = findImportDeclaration(source, baseClass); + + if (importDeclaration === undefined) { + continue; + } + + const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path); + + themeableComponents.add({ + baseClass, + basePath: basePath.replace(new RegExp(`^${prefix}`), ''), + baseFileName: basename(basePath).replace(/\.ts$/, ''), + wrapperClass, + wrapperPath: path.replace(new RegExp(`^${prefix}`), ''), + wrapperFileName: basename(path).replace(/\.ts$/, ''), + }); + } + } + } + + return; + } else { + ts.forEachChild(node, traverse); + } + } + + traverse(source); + } + + const glob = require('glob'); + + // note: this outputs Unix-style paths on Windows + const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found; + + for (const wrapper of wrappers) { + registerWrapper(wrapper); + } + } + + private add(entry: ThemeableComponentRegistryEntry) { + this.entries.add(entry); + this.byBaseClass.set(entry.baseClass, entry); + this.byWrapperClass.set(entry.wrapperClass, entry); + this.byBasePath.set(entry.basePath, entry); + this.byWrapperPath.set(entry.wrapperPath, entry); + } +} + +export const themeableComponents = new ThemeableComponentRegistry(); + +/** + * Construct the AST of a TypeScript source file + * @param file + */ +function getSource(file: string): ts.SourceFile { + return ts.createSourceFile( + file, + readFileSync(file).toString(), + ts.ScriptTarget.ES2020, // todo: actually use tsconfig.json? + /*setParentNodes */ true, + ); +} + +/** + * Resolve a possibly relative local path into an absolute path starting from the root directory of the project + */ +function resolveLocalPath(path: string, relativeTo: string) { + if (path.startsWith('src/')) { + return path; + } else if (path.startsWith('./')) { + const parts = relativeTo.split('/'); + return [ + ...parts.slice(0, parts.length - 1), + path.replace(/^.\//, ''), + ].join('/') + '.ts'; + } else { + throw new Error(`Unsupported local path: ${path}`); + } +} + +export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean { + if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) { + return false; + } + + if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return false; + } + + return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent'; +} + +export function getBaseComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined { + const wrapperClass = getComponentClassName(decoratorNode); + + if (wrapperClass === undefined) { + return; + } + + themeableComponents.initialize(); + const entry = themeableComponents.byWrapperClass.get(wrapperClass); + + if (entry === undefined) { + return undefined; + } + + return entry.baseClass; +} + +export function isThemeableComponent(className: string): boolean { + themeableComponents.initialize(); + return themeableComponents.byBaseClass.has(className); +} + +export function inThemedComponentOverrideFile(filename: string): boolean { + const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/); + + if (!match) { + return false; + } + themeableComponents.initialize(); + // todo: this is fragile! + return themeableComponents.byBasePath.has(`src/${match[1]}`); +} + +export function allThemeableComponents(): ThemeableComponentRegistryEntry[] { + themeableComponents.initialize(); + return [...themeableComponents.entries]; +} + +export function getThemeableComponentByBaseClass(baseClass: string): ThemeableComponentRegistryEntry | undefined { + themeableComponents.initialize(); + return themeableComponents.byBaseClass.get(baseClass); +} + +export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) { + return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode); +} + +export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-'; + +export function fixSelectors(text: string): string { + return text.replaceAll(/ds-(base|themed)-/g, 'ds-'); +} diff --git a/lint/src/util/typescript.ts b/lint/src/util/typescript.ts new file mode 100644 index 00000000000..0d04ef1a3d9 --- /dev/null +++ b/lint/src/util/typescript.ts @@ -0,0 +1,155 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TSESTree } from '@typescript-eslint/utils'; +import { + RuleContext, + SourceCode, +} from '@typescript-eslint/utils/ts-eslint'; + +import { + match, + toUnixStylePath, +} from './misc'; + +export type AnyRuleContext = RuleContext; + +/** + * Return the current filename based on the ESLint rule context as a Unix-style path. + * This is easier for regex and comparisons to glob paths. + */ +export function getFilename(context: AnyRuleContext): string { + // TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?) + // eslint-disable-next-line deprecation/deprecation + return toUnixStylePath(context.getFilename()); +} + +export function getSourceCode(context: AnyRuleContext): SourceCode { + // TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?) + // eslint-disable-next-line deprecation/deprecation + return context.getSourceCode(); +} + +export function getObjectPropertyNodeByName(objectNode: TSESTree.ObjectExpression, propertyName: string): TSESTree.Node | undefined { + for (const propertyNode of objectNode.properties) { + if ( + propertyNode.type === TSESTree.AST_NODE_TYPES.Property + && ( + ( + propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Identifier + && propertyNode.key?.name === propertyName + ) || ( + propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Literal + && propertyNode.key?.value === propertyName + ) + ) + ) { + return propertyNode.value; + } + } + return undefined; +} + +export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifier): TSESTree.Identifier[] { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === localNode.name && !match(token.range, localNode.range)) { + const node = source.getNodeByRangeIndex(token.range[0]); + // todo: in some cases, the resulting node can actually be the whole program (!) + if (node !== null) { + usages.push(node as TSESTree.Identifier); + } + } + } + + return usages; +} + +export function findUsagesByName(context: AnyRuleContext, identifier: string): TSESTree.Identifier[] { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === identifier) { + const node = source.getNodeByRangeIndex(token.range[0]); + // todo: in some cases, the resulting node can actually be the whole program (!) + if (node !== null) { + usages.push(node as TSESTree.Identifier); + } + } + } + + return usages; +} + +export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean { + return node.parent?.type?.valueOf().startsWith('TSType'); +} + +export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean { + return node.parent?.type === TSESTree.AST_NODE_TYPES.ClassDeclaration; +} + +function fromSrc(path: string): string { + const m = path.match(/^.*(src\/.+)(\.(ts|json|js)?)$/); + + if (m) { + return m[1]; + } else { + throw new Error(`Can't infer project-absolute TS/resource path from: ${path}`); + } +} + + +export function relativePath(thisFile: string, importFile: string): string { + const fromParts = fromSrc(thisFile).split('/'); + const toParts = fromSrc(importFile).split('/'); + + let lastCommon = 0; + for (let i = 0; i < fromParts.length - 1; i++) { + if (fromParts[i] === toParts[i]) { + lastCommon++; + } else { + break; + } + } + + const path = toParts.slice(lastCommon, toParts.length).join('/'); + const backtrack = fromParts.length - lastCommon - 1; + + let prefix: string; + if (backtrack > 0) { + prefix = '../'.repeat(backtrack); + } else { + prefix = './'; + } + + return prefix + path; +} + + +export function findImportSpecifier(context: AnyRuleContext, identifier: string): TSESTree.ImportSpecifier | undefined { + const source = getSourceCode(context); + + const usages: TSESTree.Identifier[] = []; + + for (const token of source.ast.tokens) { + if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === identifier) { + const node = source.getNodeByRangeIndex(token.range[0]); + // todo: in some cases, the resulting node can actually be the whole program (!) + if (node && node.parent && node.parent.type === TSESTree.AST_NODE_TYPES.ImportSpecifier) { + return node.parent; + } + } + } + + return undefined; +} diff --git a/lint/test/fixture/README.md b/lint/test/fixture/README.md new file mode 100644 index 00000000000..b19ae11b558 --- /dev/null +++ b/lint/test/fixture/README.md @@ -0,0 +1,9 @@ +# ESLint testing fixtures + +The files in this directory are used for the ESLint testing environment +- Some rules rely on registries that must be built up _before_ the rule is run + - In order to test these registries, the fixture sources contain a few dummy components +- The TypeScript ESLint test runner requires at least one dummy file to exist to run any tests + - By default, [`test.ts`](./src/test.ts) is used. Note that this file is empty; it's only there for the TypeScript configuration, the actual content is injected from the `code` property in the tests. + - To test rules that make assertions based on the path of the file, you'll need to include the `filename` property in the test configuration. Note that it must point to an existing file too! + - The `filename` must be provided as `fixture('src/something.ts')` \ No newline at end of file diff --git a/lint/test/fixture/index.ts b/lint/test/fixture/index.ts new file mode 100644 index 00000000000..1d4f33f7e28 --- /dev/null +++ b/lint/test/fixture/index.ts @@ -0,0 +1,13 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +export const FIXTURE = 'lint/test/fixture/'; + +export function fixture(path: string): string { + return FIXTURE + path; +} diff --git a/lint/test/fixture/src/app/test/test-routing.module.ts b/lint/test/fixture/src/app/test/test-routing.module.ts new file mode 100644 index 00000000000..1ccbccc5994 --- /dev/null +++ b/lint/test/fixture/src/app/test/test-routing.module.ts @@ -0,0 +1,14 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { ThemedTestThemeableComponent } from './themed-test-themeable.component'; + +export const ROUTES = [ + { + component: ThemedTestThemeableComponent, + }, +]; diff --git a/lint/test/fixture/src/app/test/test-themeable.component.ts b/lint/test/fixture/src/app/test/test-themeable.component.ts new file mode 100644 index 00000000000..b445040539c --- /dev/null +++ b/lint/test/fixture/src/app/test/test-themeable.component.ts @@ -0,0 +1,16 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-base-test-themeable', + template: '', + standalone: true, +}) +export class TestThemeableComponent { +} diff --git a/lint/test/fixture/src/app/test/test.component.cy.ts b/lint/test/fixture/src/app/test/test.component.cy.ts new file mode 100644 index 00000000000..2300ac4a56f --- /dev/null +++ b/lint/test/fixture/src/app/test/test.component.cy.ts @@ -0,0 +1,8 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + diff --git a/lint/test/fixture/src/app/test/test.component.spec.ts b/lint/test/fixture/src/app/test/test.component.spec.ts new file mode 100644 index 00000000000..2300ac4a56f --- /dev/null +++ b/lint/test/fixture/src/app/test/test.component.spec.ts @@ -0,0 +1,8 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + diff --git a/lint/test/fixture/src/app/test/test.component.ts b/lint/test/fixture/src/app/test/test.component.ts new file mode 100644 index 00000000000..c01f104c989 --- /dev/null +++ b/lint/test/fixture/src/app/test/test.component.ts @@ -0,0 +1,15 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-test', + template: '', +}) +export class TestComponent { +} diff --git a/lint/test/fixture/src/app/test/test.module.ts b/lint/test/fixture/src/app/test/test.module.ts new file mode 100644 index 00000000000..a37396ef459 --- /dev/null +++ b/lint/test/fixture/src/app/test/test.module.ts @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +// @ts-ignore +import { NgModule } from '@angular/core'; + +import { TestComponent } from './test.component'; +import { TestThemeableComponent } from './test-themeable.component'; +import { ThemedTestThemeableComponent } from './themed-test-themeable.component'; + +@NgModule({ + declarations: [ + TestComponent, + TestThemeableComponent, + ThemedTestThemeableComponent, + ], +}) +export class TestModule { + +} diff --git a/lint/test/fixture/src/app/test/themed-test-themeable.component.ts b/lint/test/fixture/src/app/test/themed-test-themeable.component.ts new file mode 100644 index 00000000000..2697a8c598e --- /dev/null +++ b/lint/test/fixture/src/app/test/themed-test-themeable.component.ts @@ -0,0 +1,31 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +import { ThemedComponent } from '../../../../../../src/app/shared/theme-support/themed.component'; +import { TestThemeableComponent } from './test-themeable.component'; + +@Component({ + selector: 'ds-test-themeable', + template: '', + standalone: true, + imports: [TestThemeableComponent], +}) +export class ThemedTestThemeableComponent extends ThemedComponent { + protected getComponentName(): string { + return ''; + } + + protected importThemedComponent(themeName: string): Promise { + return Promise.resolve(undefined); + } + + protected importUnthemedComponent(): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss b/lint/test/fixture/src/test.ts similarity index 100% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss rename to lint/test/fixture/src/test.ts diff --git a/lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts b/lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts new file mode 100644 index 00000000000..f72161b2bfc --- /dev/null +++ b/lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts @@ -0,0 +1,16 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-themed-test-themeable', + template: '', +}) +export class OtherThemeableComponent { + +} diff --git a/lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts b/lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts new file mode 100644 index 00000000000..d2b02ca9f1f --- /dev/null +++ b/lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts @@ -0,0 +1,18 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Component } from '@angular/core'; + +import { TestThemeableComponent as BaseComponent } from '../../../../app/test/test-themeable.component'; + +@Component({ + selector: 'ds-themed-test-themeable', + template: '', +}) +export class TestThemeableComponent extends BaseComponent { + +} diff --git a/lint/test/fixture/src/themes/test/test.module.ts b/lint/test/fixture/src/themes/test/test.module.ts new file mode 100644 index 00000000000..ff6ec3b2c0e --- /dev/null +++ b/lint/test/fixture/src/themes/test/test.module.ts @@ -0,0 +1,22 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +// @ts-ignore +import { NgModule } from '@angular/core'; + +import { OtherThemeableComponent } from './app/test/other-themeable.component'; +import { TestThemeableComponent } from './app/test/test-themeable.component'; + +@NgModule({ + declarations: [ + TestThemeableComponent, + OtherThemeableComponent, + ], +}) +export class TestModule { + +} diff --git a/lint/test/fixture/tsconfig.json b/lint/test/fixture/tsconfig.json new file mode 100644 index 00000000000..0fd1141ae0e --- /dev/null +++ b/lint/test/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*.ts" + ], + "exclude": [] +} diff --git a/lint/test/helpers.js b/lint/test/helpers.js new file mode 100644 index 00000000000..bd648d007f5 --- /dev/null +++ b/lint/test/helpers.js @@ -0,0 +1,13 @@ +const SpecReporter = require('jasmine-spec-reporter').SpecReporter; +const StacktraceOption = require('jasmine-spec-reporter').StacktraceOption; + +jasmine.getEnv().clearReporters(); // Clear default console reporter for those instead +jasmine.getEnv().addReporter(new SpecReporter({ + spec: { + displayErrorMessages: false, + }, + summary: { + displayFailed: true, + displayStacktrace: StacktraceOption.PRETTY, + }, +})); diff --git a/lint/test/rules.spec.ts b/lint/test/rules.spec.ts new file mode 100644 index 00000000000..11c9bec46cf --- /dev/null +++ b/lint/test/rules.spec.ts @@ -0,0 +1,26 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { default as htmlPlugin } from '../src/rules/html'; +import { default as tsPlugin } from '../src/rules/ts'; +import { + htmlRuleTester, + tsRuleTester, +} from './testing'; + +describe('TypeScript rules', () => { + for (const { info, rule, tests } of tsPlugin.index) { + tsRuleTester.run(info.name, rule, tests as any); + } +}); + +describe('HTML rules', () => { + for (const { info, rule, tests } of htmlPlugin.index) { + htmlRuleTester.run(info.name, rule, tests); + } +}); diff --git a/lint/test/structure.spec.ts b/lint/test/structure.spec.ts new file mode 100644 index 00000000000..24e69e42d9a --- /dev/null +++ b/lint/test/structure.spec.ts @@ -0,0 +1,76 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { default as html } from '../src/rules/html'; +import { default as ts } from '../src/rules/ts'; + +describe('plugin structure', () => { + for (const pluginExports of [ts, html]) { + const pluginName = pluginExports.name ?? 'UNNAMED PLUGIN'; + + describe(pluginName, () => { + it('should have a name', () => { + expect(pluginExports.name).toBeTruthy(); + }); + + it('should have rules', () => { + expect(pluginExports.index).toBeTruthy(); + expect(pluginExports.rules).toBeTruthy(); + expect(pluginExports.index.length).toBeGreaterThan(0); + }); + + for (const ruleExports of pluginExports.index) { + const ruleName = ruleExports.info.name ?? 'UNNAMED RULE'; + + describe(ruleName, () => { + it('should have a name', () => { + expect(ruleExports.info.name).toBeTruthy(); + }); + + it('should be included under the right name in the plugin', () => { + expect(pluginExports.rules[ruleExports.info.name]).toBe(ruleExports.rule); + }); + + it('should contain metadata', () => { + expect(ruleExports.info).toBeTruthy(); + expect(ruleExports.info.name).toBeTruthy(); + expect(ruleExports.info.meta).toBeTruthy(); + expect(ruleExports.info.defaultOptions).toBeTruthy(); + }); + + it('should contain messages', () => { + expect(ruleExports.Message).toBeTruthy(); + expect(ruleExports.info.meta.messages).toBeTruthy(); + }); + + describe('messages', () => { + for (const member of Object.keys(ruleExports.Message)) { + describe(member, () => { + const id = (ruleExports.Message as any)[member]; + + it('should have a valid ID', () => { + expect(id).toBeTruthy(); + }); + + it('should have valid metadata', () => { + expect(ruleExports.info.meta.messages[id]).toBeTruthy(); + }); + }); + } + }); + + it('should contain tests', () => { + expect(ruleExports.tests).toBeTruthy(); + expect(ruleExports.tests.valid.length).toBeGreaterThan(0); + expect(ruleExports.tests.invalid.length).toBeGreaterThan(0); + }); + }); + } + }); + } +}); diff --git a/lint/test/testing.ts b/lint/test/testing.ts new file mode 100644 index 00000000000..53faf320699 --- /dev/null +++ b/lint/test/testing.ts @@ -0,0 +1,53 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester'; +import { RuleTester } from '@typescript-eslint/utils/ts-eslint'; + +import { themeableComponents } from '../src/util/theme-support'; +import { + FIXTURE, + fixture, +} from './fixture'; + + +// Register themed components from test fixture +themeableComponents.initialize(FIXTURE); + +TypeScriptRuleTester.itOnly = fit; +TypeScriptRuleTester.itSkip = xit; + +export const tsRuleTester = new TypeScriptRuleTester({ + parser: '@typescript-eslint/parser', + defaultFilenames: { + ts: fixture('src/test.ts'), + tsx: 'n/a', + }, + parserOptions: { + project: fixture('tsconfig.json'), + }, +}); + +class HtmlRuleTester extends RuleTester { + run(name: string, rule: any, tests: { valid: any[], invalid: any[] }) { + super.run(name, rule, { + valid: tests.valid.map((test) => ({ + filename: fixture('test.html'), + ...test, + })), + invalid: tests.invalid.map((test) => ({ + filename: fixture('test.html'), + ...test, + })), + }); + } +} + +export const htmlRuleTester = new HtmlRuleTester({ + parser: require.resolve('@angular-eslint/template-parser'), +}); diff --git a/lint/test/theme-support.spec.ts b/lint/test/theme-support.spec.ts new file mode 100644 index 00000000000..2edf9594b62 --- /dev/null +++ b/lint/test/theme-support.spec.ts @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { themeableComponents } from '../src/util/theme-support'; + +describe('theme-support', () => { + describe('themeable component registry', () => { + it('should contain all themeable components from the fixture', () => { + expect(themeableComponents.entries.size).toBe(1); + expect(themeableComponents.byBasePath.size).toBe(1); + expect(themeableComponents.byWrapperPath.size).toBe(1); + expect(themeableComponents.byBaseClass.size).toBe(1); + + expect(themeableComponents.byBaseClass.get('TestThemeableComponent')).toBeTruthy(); + expect(themeableComponents.byBasePath.get('src/app/test/test-themeable.component.ts')).toBeTruthy(); + expect(themeableComponents.byWrapperPath.get('src/app/test/themed-test-themeable.component.ts')).toBeTruthy(); + }); + }); +}); diff --git a/lint/tsconfig.json b/lint/tsconfig.json new file mode 100644 index 00000000000..d3537a73762 --- /dev/null +++ b/lint/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": [ + "es2021" + ], + "module": "nodenext", + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "skipLibCheck": true, + "strict": true, + "outDir": "./dist", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "types": [ + "jasmine", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "dist", + "test/fixture" + ] +} diff --git a/package.json b/package.json index 69fff84bb40..a4f2ac9b37c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "7.6.1-next", + "version": "8.3.0", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -12,17 +12,21 @@ "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 --configuration development", "build:stats": "ng build --stats-json", - "build:prod": "yarn run build:ssr", + "build:prod": "cross-env NODE_ENV=production yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", + "build:lint": "rimraf 'lint/dist/**/*.js' 'lint/dist/**/*.js.map' && tsc -b lint/tsconfig.json", "test": "ng test --source-map=true --watch=false --configuration test", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", - "lint": "ng lint", - "lint-fix": "ng lint --fix=true", - "e2e": "ng e2e", + "test:lint": "yarn build:lint && yarn test:lint:nobuild", + "test:lint:nobuild": "jasmine --config=lint/jasmine.json", + "lint": "yarn build:lint && yarn lint:nobuild", + "lint:nobuild": "ng lint", + "lint-fix": "yarn build:lint && ng lint --fix=true", + "docs:lint": "ts-node --project ./lint/tsconfig.json ./lint/generate-docs.ts", + "e2e": "cross-env NODE_ENV=production ng e2e", "clean:dev:config": "rimraf src/assets/config.json", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", @@ -40,7 +44,8 @@ "cypress:run": "cypress run", "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts", "base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts", - "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./" + "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./", + "postinstall": "yarn build:lint || echo 'Skipped DSpace ESLint plugins.'" }, "browser": { "fs": false, @@ -49,132 +54,127 @@ "https": false }, "private": true, - "resolutions": { - "minimist": "^1.2.5", - "webdriver-manager": "^12.1.8", - "ts-node": "10.2.1" - }, "dependencies": { - "@angular/animations": "^15.2.8", - "@angular/cdk": "^15.2.8", - "@angular/common": "^15.2.8", - "@angular/compiler": "^15.2.8", - "@angular/core": "^15.2.8", - "@angular/forms": "^15.2.8", - "@angular/localize": "15.2.8", - "@angular/platform-browser": "^15.2.8", - "@angular/platform-browser-dynamic": "^15.2.8", - "@angular/platform-server": "^15.2.8", - "@angular/router": "^15.2.8", - "@babel/runtime": "7.21.0", + "@angular/animations": "^17.3.11", + "@angular/cdk": "^17.3.10", + "@angular/common": "^17.3.11", + "@angular/compiler": "^17.3.11", + "@angular/core": "^17.3.11", + "@angular/forms": "^17.3.11", + "@angular/localize": "17.3.12", + "@angular/platform-browser": "^17.3.11", + "@angular/platform-browser-dynamic": "^17.3.11", + "@angular/platform-server": "^17.3.11", + "@angular/router": "^17.3.11", + "@angular/ssr": "^17.3.17", + "@babel/runtime": "7.28.4", "@kolkov/ngx-gallery": "^2.0.1", - "@material-ui/core": "^4.11.0", - "@material-ui/icons": "^4.11.3", "@ng-bootstrap/ng-bootstrap": "^11.0.0", - "@ng-dynamic-forms/core": "^15.0.0", - "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0", - "@ngrx/effects": "^15.4.0", - "@ngrx/router-store": "^15.4.0", - "@ngrx/store": "^15.4.0", - "@nguniversal/express-engine": "^15.2.1", + "@ng-dynamic-forms/core": "^16.0.0", + "@ng-dynamic-forms/ui-ng-bootstrap": "^16.0.0", + "@ngrx/effects": "^17.1.1", + "@ngrx/router-store": "^17.1.1", + "@ngrx/store": "^17.1.1", "@ngx-translate/core": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", - "@types/grecaptcha": "^3.0.4", - "angular-idle-preload": "3.0.0", "angulartics2": "^12.2.0", - "axios": "^0.27.2", + "axios": "^1.13.2", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", "colors": "^1.4.0", - "compression": "^1.7.4", - "cookie-parser": "1.4.6", - "core-js": "^3.30.1", + "compression": "^1.8.1", + "cookie-parser": "1.4.7", + "core-js": "^3.47.0", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", - "ejs": "^3.1.9", - "express": "^4.18.2", + "ejs": "^3.1.10", + "express": "^4.21.2", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", - "http-proxy-middleware": "^1.0.5", + "http-proxy-middleware": "^2.0.9", "http-terminator": "^3.2.0", - "isbot": "^3.6.10", + "isbot": "^5.1.32", "js-cookie": "2.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "json5": "^2.2.3", - "jsonschema": "1.4.1", + "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", "klaro": "^0.7.18", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", - "markdown-it-mathjax3": "^4.3.2", - "mirador": "^3.3.0", + "mirador": "^3.4.3", "mirador-dl-plugin": "^0.13.0", - "mirador-share-plugin": "^0.11.0", - "morgan": "^1.10.0", - "ng-mocks": "^14.10.0", - "ng2-file-upload": "1.4.0", + "mirador-share-plugin": "^0.16.0", + "morgan": "^1.10.1", + "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", - "ngx-infinite-scroll": "^15.0.0", + "ngx-infinite-scroll": "^16.0.0", "ngx-pagination": "6.0.3", - "ngx-sortablejs": "^11.1.0", - "ngx-ui-switch": "^14.0.3", + "ngx-skeleton-loader": "^9.0.0", + "ngx-ui-switch": "^14.1.0", "nouislider": "^15.7.1", - "pem": "1.14.7", - "prop-types": "^15.8.1", - "react-copy-to-clipboard": "^5.1.0", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.0", - "sanitize-html": "^2.10.0", - "sortablejs": "1.15.0", + "pem": "1.14.8", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "uuid": "^8.3.2", - "webfontloader": "1.6.28", - "zone.js": "~0.11.5" + "zone.js": "~0.14.0" }, "devDependencies": { - "@angular-builders/custom-webpack": "~15.0.0", - "@angular-devkit/build-angular": "^15.2.6", - "@angular-eslint/builder": "15.2.1", - "@angular-eslint/eslint-plugin": "15.2.1", - "@angular-eslint/eslint-plugin-template": "15.2.1", - "@angular-eslint/schematics": "15.2.1", - "@angular-eslint/template-parser": "15.2.1", - "@angular/cli": "^15.2.6", - "@angular/compiler-cli": "^15.2.8", - "@angular/language-service": "^15.2.8", + "@angular-builders/custom-webpack": "~17.0.2", + "@angular-devkit/build-angular": "^17.3.17", + "@angular-eslint/builder": "17.5.3", + "@angular-eslint/bundled-angular-compiler": "17.5.3", + "@angular-eslint/eslint-plugin": "17.5.3", + "@angular-eslint/eslint-plugin-template": "17.5.3", + "@angular-eslint/schematics": "17.5.3", + "@angular-eslint/template-parser": "17.5.3", + "@angular/cli": "^17.3.17", + "@angular/compiler-cli": "^17.3.11", + "@angular/language-service": "^17.3.11", "@cypress/schematic": "^1.5.0", - "@fortawesome/fontawesome-free": "^6.4.0", - "@ngrx/store-devtools": "^15.4.0", - "@ngtools/webpack": "^15.2.6", - "@nguniversal/builders": "^15.2.1", - "@types/deep-freeze": "0.1.2", + "@fortawesome/fontawesome-free": "^6.7.2", + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "^4.11.3", + "@ngrx/store-devtools": "^17.1.1", + "@ngtools/webpack": "^16.2.16", + "@types/deep-freeze": "0.1.5", "@types/ejs": "^3.1.2", "@types/express": "^4.17.17", + "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.14.194", + "@types/lodash": "^4.17.21", "@types/node": "^14.14.9", - "@types/sanitize-html": "^2.9.0", - "@typescript-eslint/eslint-plugin": "^5.59.1", - "@typescript-eslint/parser": "^5.59.1", - "axe-core": "^4.7.2", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@typescript-eslint/rule-tester": "^7.2.0", + "@typescript-eslint/utils": "^7.2.0", + "axe-core": "^4.11.0", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "cypress": "12.17.4", - "cypress-axe": "^1.4.0", + "csstype": "^3.2.3", + "cypress": "^13.17.0", + "cypress-axe": "^1.7.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^39.6.4", - "eslint-plugin-jsonc": "^2.6.0", + "eslint-plugin-dspace-angular-html": "link:./lint/dist/src/rules/html", + "eslint-plugin-dspace-angular-ts": "link:./lint/dist/src/rules/ts", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import-newlines": "^1.3.1", + "eslint-plugin-jsdoc": "^45.0.0", + "eslint-plugin-jsonc": "^2.21.0", "eslint-plugin-lodash": "^7.4.0", - "eslint-plugin-unused-imports": "^2.0.0", - "express-static-gzip": "^2.1.7", + "eslint-plugin-rxjs": "^5.0.3", + "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-unused-imports": "^3.2.0", + "express-static-gzip": "^2.2.0", + "jasmine": "^3.8.0", "jasmine-core": "^3.8.0", "jasmine-marbles": "0.9.2", "karma": "^6.4.2", @@ -183,26 +183,25 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ngx-mask": "^13.1.7", + "ng-mocks": "^14.14.0", + "ngx-mask": "14.2.4", "nodemon": "^2.0.22", - "postcss": "^8.4", - "postcss-apply": "0.12.0", + "postcss": "^8.5", "postcss-import": "^14.0.0", "postcss-loader": "^4.0.3", "postcss-preset-env": "^7.4.2", - "postcss-responsive-type": "1.0.0", + "prop-types": "^15.8.1", "react": "^16.14.0", + "react-copy-to-clipboard": "^5.1.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "rxjs-spy": "^8.0.2", - "sass": "~1.62.0", + "sass": "~1.94.2", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", - "typescript": "~4.8.4", - "webpack": "5.76.1", - "webpack-bundle-analyzer": "^4.8.0", - "webpack-cli": "^4.2.0", - "webpack-dev-server": "^4.13.3" + "typescript": "~5.4.5", + "webpack": "5.101.0", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.2" } } diff --git a/postcss.config.js b/postcss.config.js index df092d1d39f..f8b9666b312 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,8 +1,6 @@ module.exports = { plugins: [ require('postcss-import')(), - require('postcss-preset-env')(), - require('postcss-apply')(), - require('postcss-responsive-type')() + require('postcss-preset-env')() ] }; diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index 96ba0d40105..6b3881b3b82 100644 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -38,11 +38,13 @@ function parseCliInput() { .usage('([-d ] [-s ]) || (-t (-i | -o ) [-s ])') .parse(process.argv); - if (!program.targetFile) { + const sourceFile = program.opts().sourceFile; + + if (!program.targetFile) { fs.readdirSync(projectRoot(LANGUAGE_FILES_LOCATION)).forEach(file => { - if (!program.sourceFile.toString().endsWith(file)) { + if (!sourceFile.toString().endsWith(file)) { const targetFileLocation = projectRoot(LANGUAGE_FILES_LOCATION + "/" + file); - console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + program.sourceFile); + console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + sourceFile); if (program.outputDir) { if (!fs.existsSync(program.outputDir)) { fs.mkdirSync(program.outputDir); @@ -67,7 +69,7 @@ function parseCliInput() { console.log(program.outputHelp()); process.exit(1); } - if (!checkIfFileExists(program.sourceFile)) { + if (!checkIfFileExists(sourceFile)) { console.error('Path of source file is not valid.'); console.log(program.outputHelp()); process.exit(1); @@ -101,7 +103,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { targetLines.push(line.trim()); })); progressBar.update(10); - const sourceFile = readFileIfExists(program.sourceFile); + const sourceFile = readFileIfExists(program.opts().sourceFile); sourceFile.toString().split("\n").forEach((function (line) { sourceLines.push(line.trim()); })); @@ -275,7 +277,9 @@ function readFileIfExists(pathToFile) { try { return fs.readFileSync(pathToFile, 'utf8'); } catch (e) { - console.error('Error:', e.stack); + if (e instanceof Error) { + console.error('Error:', e.stack); + } } } return null; diff --git a/server.ts b/server.ts index da085f372fd..1005374088d 100644 --- a/server.ts +++ b/server.ts @@ -17,7 +17,6 @@ import 'zone.js/node'; import 'reflect-metadata'; -import 'rxjs'; /* eslint-disable import/no-namespace */ import * as morgan from 'morgan'; @@ -28,7 +27,7 @@ import * as expressStaticGzip from 'express-static-gzip'; /* eslint-enable import/no-namespace */ import axios from 'axios'; import LRU from 'lru-cache'; -import isbot from 'isbot'; +import { isbot } from 'isbot'; import { createCertificate } from 'pem'; import { createServer } from 'https'; import { json } from 'body-parser'; @@ -39,23 +38,27 @@ import { join } from 'path'; import { enableProdMode } from '@angular/core'; -import { ngExpressEngine } from '@nguniversal/express-engine'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasNoValue, hasValue } from './src/app/shared/empty.util'; - +import { hasValue } from './src/app/shared/empty.util'; import { UIServerConfig } from './src/config/ui-server-config.interface'; - -import { ServerAppModule } from './src/main.server'; - +import bootstrap from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; -import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; +import { + APP_CONFIG, + AppConfig, +} from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; import { logStartupMessage } from './startup-message'; import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model'; - +import { CommonEngine } from '@angular/ssr'; +import { APP_BASE_HREF } from '@angular/common'; +import { + REQUEST, + RESPONSE, +} from './src/express.tokens'; +import { SsrExcludePatterns } from "./src/config/ssr-config.interface"; /* * Set path for the browser application's dist folder @@ -79,6 +82,9 @@ let anonymousCache: LRU; // extend environment with app config for server extendEnvironmentWithAppConfig(environment, appConfig); +// The REST server base URL +const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl; + // The Express app is exported so that it can be used by serverless Functions. export function app() { @@ -127,27 +133,6 @@ export function app() { */ server.use(json()); - // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) - server.engine('html', (_, options, callback) => - ngExpressEngine({ - bootstrap: ServerAppModule, - providers: [ - { - provide: REQUEST, - useValue: (options as any).req, - }, - { - provide: RESPONSE, - useValue: (options as any).req.res, - }, - { - provide: APP_CONFIG, - useValue: environment - } - ] - })(_, (options as any), callback) - ); - server.engine('ejs', ejs.renderFile); /* @@ -162,7 +147,7 @@ export function app() { server.get('/robots.txt', (req, res) => { res.setHeader('content-type', 'text/plain'); res.render('assets/robots.txt.ejs', { - 'origin': req.protocol + '://' + req.headers.host + 'origin': req.protocol + '://' + req.headers.host, }); }); @@ -175,18 +160,18 @@ export function app() { * Proxy the sitemaps */ router.use('/sitemap**', createProxyMiddleware({ - target: `${environment.rest.baseUrl}/sitemaps`, + target: `${REST_BASE_URL}/sitemaps`, pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), - changeOrigin: true + changeOrigin: true, })); /** * Proxy the linksets */ router.use('/signposting**', createProxyMiddleware({ - target: `${environment.rest.baseUrl}`, + target: `${REST_BASE_URL}`, pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), - changeOrigin: true + changeOrigin: true, })); /** @@ -197,7 +182,7 @@ export function app() { const RateLimit = require('express-rate-limit'); const limiter = new RateLimit({ windowMs: (environment.ui as UIServerConfig).rateLimiter.windowMs, - max: (environment.ui as UIServerConfig).rateLimiter.max + max: (environment.ui as UIServerConfig).rateLimiter.max, }); server.use(limiter); } @@ -236,10 +221,10 @@ export function app() { /* * The callback function to serve server side angular */ -function ngApp(req, res) { - if (environment.universal.preboot) { +function ngApp(req, res, next) { + if (environment.ssr.enabled && req.method === 'GET' && (req.path === '/' || !isExcludedFromSsr(req.path, environment.ssr.excludePathPatterns))) { // Render the page to user via SSR (server side rendering) - serverSideRender(req, res); + serverSideRender(req, res, next); } else { // If preboot is disabled, just serve the client console.log('Universal off, serving for direct client-side rendering (CSR)'); @@ -252,54 +237,97 @@ function ngApp(req, res) { * returned to the user. * @param req current request * @param res current response + * @param next the next function * @param sendToUser if true (default), send the rendered content to the user. * If false, then only save this rendered content to the in-memory cache (to refresh cache). */ -function serverSideRender(req, res, sendToUser: boolean = true) { +function serverSideRender(req, res, next, sendToUser: boolean = true) { + const { protocol, originalUrl, baseUrl, headers } = req; + const commonEngine = new CommonEngine({ enablePerformanceProfiler: environment.ssr.enablePerformanceProfiler }); // Render the page via SSR (server side rendering) - res.render(indexHtml, { - req, - res, - preboot: environment.universal.preboot, - async: environment.universal.async, - time: environment.universal.time, - baseUrl: environment.ui.nameSpace, - originUrl: environment.ui.baseUrl, - requestUrl: req.originalUrl, - }, (err, data) => { - if (hasNoValue(err) && hasValue(data)) { - // save server side rendered page to cache (if any are enabled) - saveToCache(req, data); - if (sendToUser) { - res.locals.ssr = true; // mark response as SSR (enables text compression) - // send rendered page to user - res.send(data); + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + inlineCriticalCss: environment.ssr.inlineCriticalCss, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: DIST_FOLDER, + providers: [ + { provide: APP_BASE_HREF, useValue: baseUrl }, + { + provide: REQUEST, + useValue: req, + }, + { + provide: RESPONSE, + useValue: res, + }, + { + provide: APP_CONFIG, + useValue: environment, + }, + ], + }) + .then((html) => { + // If headers were already sent, then do nothing else, it is probably a + // redirect response + if (res.headersSent) { + return; } - } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { - // When this error occurs we can't fall back to CSR because the response has already been - // sent. These errors occur for various reasons in universal, not all of which are in our - // control to solve. - console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client'); - } else { - console.warn('Error in server-side rendering (SSR)'); - if (hasValue(err)) { - console.warn('Error details : ', err); + + if (hasValue(html)) { + // Replace REST URL with UI URL + if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { + html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); + } + + // save server side rendered page to cache (if any are enabled) + saveToCache(req, html); + if (sendToUser) { + res.locals.ssr = true; // mark response as SSR (enables text compression) + // send rendered page to user + res.send(html); + } } - if (sendToUser) { - console.warn('Falling back to serving direct client-side rendering (CSR).'); - clientSideRender(req, res); + }) + .catch((err) => { + if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { + // When this error occurs we can't fall back to CSR because the response has already been + // sent. These errors occur for various reasons in universal, not all of which are in our + // control to solve. + console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client'); + } else { + console.warn('Error in server-side rendering (SSR)'); + if (hasValue(err)) { + console.warn('Error details : ', err); + } + if (sendToUser) { + console.warn('Falling back to serving direct client-side rendering (CSR).'); + clientSideRender(req, res); + } } - } - }); + next(err); + }); } -/** - * Send back response to user to trigger direct client-side rendering (CSR) - * @param req current request - * @param res current response - */ +// Read file once at startup +const indexHtmlContent = readFileSync(indexHtml, 'utf8'); + function clientSideRender(req, res) { - res.sendFile(indexHtml); + const namespace = environment.ui.nameSpace || '/'; + let html = indexHtmlContent; + // Replace base href dynamically + html = html.replace( + //, + `` + ); + + // Replace REST URL with UI URL + if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { + html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); + } + + res.send(html); } @@ -325,7 +353,7 @@ function initCache() { botCache = new LRU( { max: environment.cache.serverSide.botCache.max, ttl: environment.cache.serverSide.botCache.timeToLive, - allowStale: environment.cache.serverSide.botCache.allowStale + allowStale: environment.cache.serverSide.botCache.allowStale, }); } @@ -337,7 +365,7 @@ function initCache() { anonymousCache = new LRU( { max: environment.cache.serverSide.anonymousCache.max, ttl: environment.cache.serverSide.anonymousCache.timeToLive, - allowStale: environment.cache.serverSide.anonymousCache.allowStale + allowStale: environment.cache.serverSide.anonymousCache.allowStale, }); } } @@ -348,7 +376,7 @@ function initCache() { function botCacheEnabled(): boolean { // Caching is only enabled if SSR is enabled AND // "max" pages to cache is greater than zero - return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0); + return environment.ssr.enabled && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0); } /** @@ -357,7 +385,7 @@ function botCacheEnabled(): boolean { function anonymousCacheEnabled(): boolean { // Caching is only enabled if SSR is enabled AND // "max" pages to cache is greater than zero - return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0); + return environment.ssr.enabled && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0); } /** @@ -370,9 +398,9 @@ function cacheCheck(req, res, next) { // If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page. if (botCacheEnabled() && isbot(req.get('user-agent'))) { - cachedCopy = checkCacheForRequest('bot', botCache, req, res); + cachedCopy = checkCacheForRequest('bot', botCache, req, res, next); } else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) { - cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res); + cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res, next); } // If cached copy exists, return it to the user. @@ -408,14 +436,15 @@ function cacheCheck(req, res, next) { * @param cache LRU cache to check * @param req current request to look for in the cache * @param res current response + * @param next the next function * @returns cached copy (if found) or undefined (if not found) */ -function checkCacheForRequest(cacheName: string, cache: LRU, req, res): any { +function checkCacheForRequest(cacheName: string, cache: LRU, req, res, next): any { // Get the cache key for this request const key = getCacheKey(req); // Check if this page is in our cache - let cachedCopy = cache.get(key); + const cachedCopy = cache.get(key); if (cachedCopy) { if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); } @@ -426,7 +455,7 @@ function checkCacheForRequest(cacheName: string, cache: LRU, req, r // Update cached copy by rerendering server-side // NOTE: In this scenario the currently cached copy will be returned to the current user. // This re-render is peformed behind the scenes to update cached copy for next user. - serverSideRender(req, res, false); + serverSideRender(req, res, next, false); } } else { if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); } @@ -529,28 +558,28 @@ function serverStarted() { function createHttpsServer(keys) { const listener = createServer({ key: keys.serviceKey, - cert: keys.certificate - }, app).listen(environment.ui.port, environment.ui.host, () => { + cert: keys.certificate, + }, app()).listen(environment.ui.port, environment.ui.host, () => { serverStarted(); }); // Graceful shutdown when signalled - const terminator = createHttpTerminator({server: listener}); + const terminator = createHttpTerminator({ server: listener }); process.on('SIGINT', () => { - void (async ()=> { - console.debug('Closing HTTPS server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); - console.debug('HTTPS server closed'); - })(); - }); + void (async ()=> { + console.debug('Closing HTTPS server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTPS server closed'); + })(); + }); } /** * Create an HTTP server with the configured port and host. */ function run() { - const port = environment.ui.port || 4000; - const host = environment.ui.host || '/'; + const port = environment.ui.port; + const host = environment.ui.host; // Start up the Node server const server = app(); @@ -559,14 +588,14 @@ function run() { }); // Graceful shutdown when signalled - const terminator = createHttpTerminator({server: listener}); + const terminator = createHttpTerminator({ server: listener }); process.on('SIGINT', () => { - void (async () => { - console.debug('Closing HTTP server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); - console.debug('HTTP server closed.');return undefined; - })(); - }); + void (async () => { + console.debug('Closing HTTP server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTP server closed.');return undefined; + })(); + }); } function start() { @@ -597,7 +626,7 @@ function start() { if (serviceKey && certificate) { createHttpsServer({ serviceKey: serviceKey, - certificate: certificate + certificate: certificate, }); } else { console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); @@ -606,7 +635,7 @@ function start() { createCertificate({ days: 1, - selfSigned: true + selfSigned: true, }, (error, keys) => { createHttpsServer(keys); }); @@ -616,18 +645,33 @@ function start() { } } +/** + * Check if SSR should be skipped for path + * + * @param path + * @param excludePathPattern + */ +function isExcludedFromSsr(path: string, excludePathPattern: SsrExcludePatterns[]): boolean { + const patterns = excludePathPattern.map(p => + new RegExp(p.pattern, p.flag || '') + ); + return patterns.some((regex) => { + return regex.test(path) + }); +} + /* * The callback function to serve health check requests */ function healthCheck(req, res) { - const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`; + const baseUrl = `${REST_BASE_URL}${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 + error: error.message, }); }); } diff --git a/src/app/access-control/access-control-routes.ts b/src/app/access-control/access-control-routes.ts new file mode 100644 index 00000000000..07b6f6c4ff4 --- /dev/null +++ b/src/app/access-control/access-control-routes.ts @@ -0,0 +1,114 @@ +import { AbstractControl } from '@angular/forms'; +import { Route } from '@angular/router'; +import { + DYNAMIC_ERROR_MESSAGES_MATCHER, + DynamicErrorMessagesMatcher, +} from '@ng-dynamic-forms/core'; + +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { groupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { siteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + EPERSON_PATH, + GROUP_PATH, +} from './access-control-routing-paths'; +import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; +import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; +import { GroupFormComponent } from './group-registry/group-form/group-form.component'; +import { groupPageGuard } from './group-registry/group-page.guard'; +import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; + +/** + * Condition for displaying error messages on email form field + */ +export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = + (control: AbstractControl, model: any, hasFocus: boolean) => { + return ( control.touched && !hasFocus ) || ( control.errors?.emailTaken && hasFocus ); + }; + +const providers = [ + { + provide: DYNAMIC_ERROR_MESSAGES_MATCHER, + useValue: ValidateEmailErrorStateMatcher, + }, +]; +export const ROUTES: Route[] = [ + { + path: EPERSON_PATH, + component: EPeopleRegistryComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, + canActivate: [siteAdministratorGuard], + }, + { + path: `${EPERSON_PATH}/create`, + component: EPersonFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' }, + canActivate: [siteAdministratorGuard], + }, + { + path: `${EPERSON_PATH}/:id/edit`, + component: EPersonFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + ePerson: EPersonResolver, + }, + providers, + data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' }, + canActivate: [siteAdministratorGuard], + }, + { + path: GROUP_PATH, + component: GroupsRegistryComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, + canActivate: [groupAdministratorGuard], + }, + { + path: `${GROUP_PATH}/create`, + component: GroupFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { + title: 'admin.access-control.groups.title.addGroup', + breadcrumbKey: 'admin.access-control.groups.addGroup', + }, + canActivate: [groupAdministratorGuard], + }, + { + path: `${GROUP_PATH}/:groupId/edit`, + component: GroupFormComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + providers, + data: { + title: 'admin.access-control.groups.title.singleGroup', + breadcrumbKey: 'admin.access-control.groups.singleGroup', + }, + canActivate: [groupPageGuard], + }, + { + path: 'bulk-access', + component: BulkAccessComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, + canActivate: [siteAdministratorGuard], + }, +]; diff --git a/src/app/access-control/access-control-routing-paths.ts b/src/app/access-control/access-control-routing-paths.ts index 259aa311e74..06ae0321945 100644 --- a/src/app/access-control/access-control-routing-paths.ts +++ b/src/app/access-control/access-control-routing-paths.ts @@ -1,12 +1,22 @@ -import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAccessControlModuleRoute } from '../app-routing-paths'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; + +export const EPERSON_PATH = 'epeople'; + +export function getEPersonsRoute(): string { + return new URLCombiner(getAccessControlModuleRoute(), EPERSON_PATH).toString(); +} + +export function getEPersonEditRoute(id: string): string { + return new URLCombiner(getEPersonsRoute(), id, 'edit').toString(); +} -export const GROUP_EDIT_PATH = 'groups'; +export const GROUP_PATH = 'groups'; export function getGroupsRoute() { - return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString(); + return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString(); } export function getGroupEditRoute(id: string) { - return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); + return new URLCombiner(getGroupsRoute(), id, 'edit').toString(); } diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts deleted file mode 100644 index 6f6de6cb263..00000000000 --- a/src/app/access-control/access-control-routing.module.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; -import { GroupFormComponent } from './group-registry/group-form/group-form.component'; -import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { GROUP_EDIT_PATH } from './access-control-routing-paths'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { GroupPageGuard } from './group-registry/group-page.guard'; -import { - GroupAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { - SiteAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; -import { BulkAccessComponent } from './bulk-access/bulk-access.component'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: 'epeople', - component: EPeopleRegistryComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, - canActivate: [SiteAdministratorGuard] - }, - { - path: GROUP_EDIT_PATH, - component: GroupsRegistryComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, - canActivate: [GroupAdministratorGuard] - }, - { - path: `${GROUP_EDIT_PATH}/newGroup`, - component: GroupFormComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' }, - canActivate: [GroupAdministratorGuard] - }, - { - path: `${GROUP_EDIT_PATH}/:groupId`, - component: GroupFormComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, - canActivate: [GroupPageGuard] - }, - { - path: 'bulk-access', - component: BulkAccessComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, - canActivate: [SiteAdministratorGuard] - }, - ]) - ] -}) -/** - * Routing module for the AccessControl section of the admin sidebar - */ -export class AccessControlRoutingModule { - -} diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts deleted file mode 100644 index 3dc4b6cedc7..00000000000 --- a/src/app/access-control/access-control.module.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { SharedModule } from '../shared/shared.module'; -import { AccessControlRoutingModule } from './access-control-routing.module'; -import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; -import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; -import { GroupFormComponent } from './group-registry/group-form/group-form.component'; -import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component'; -import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component'; -import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { FormModule } from '../shared/form/form.module'; -import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core'; -import { AbstractControl } from '@angular/forms'; -import { BulkAccessComponent } from './bulk-access/bulk-access.component'; -import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; -import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component'; -import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component'; -import { SearchModule } from '../shared/search/search.module'; -import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module'; - -/** - * Condition for displaying error messages on email form field - */ -export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = - (control: AbstractControl, model: any, hasFocus: boolean) => { - return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus); - }; - -@NgModule({ - imports: [ - CommonModule, - SharedModule, - RouterModule, - AccessControlRoutingModule, - FormModule, - NgbAccordionModule, - SearchModule, - AccessControlFormModule, - ], - exports: [ - MembersListComponent, - ], - declarations: [ - EPeopleRegistryComponent, - EPersonFormComponent, - GroupsRegistryComponent, - GroupFormComponent, - SubgroupsListComponent, - MembersListComponent, - BulkAccessComponent, - BulkAccessBrowseComponent, - BulkAccessSettingsComponent, - ], - providers: [ - { - provide: DYNAMIC_ERROR_MESSAGES_MATCHER, - useValue: ValidateEmailErrorStateMatcher - }, - ] -}) -/** - * This module handles all components related to the access control pages - */ -export class AccessControlModule { - -} diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html index c716aedb8b3..f96ddf4a235 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html @@ -1,15 +1,15 @@ -
-
-
+
+
@@ -17,51 +17,52 @@
- -
+
diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts index 87b2a8d5684..f9eb487d73a 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts @@ -1,16 +1,28 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; - -import { of } from 'rxjs'; -import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + NgbAccordionModule, + NgbNavModule, +} from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; -import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { getMockThemeService } from '../../../shared/mocks/theme-service.mock'; +import { ListableObjectComponentLoaderComponent } from '../../../shared/object-collection/shared/listable-object/listable-object-component-loader.component'; +import { SelectableListItemControlComponent } from '../../../shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec'; -import { PageInfo } from '../../../core/shared/page-info.model'; -import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ThemedSearchComponent } from '../../../shared/search/themed-search.component'; +import { ThemeService } from '../../../shared/theme-support/theme.service'; +import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; describe('BulkAccessBrowseComponent', () => { let component: BulkAccessBrowseComponent; @@ -23,7 +35,7 @@ describe('BulkAccessBrowseComponent', () => { const selected1 = new SelectableObject(value1); const selected2 = new SelectableObject(value2); - const testSelection = { id: listID1, selection: [selected1, selected2] } ; + const testSelection = { id: listID1, selection: [selected1, selected2] }; const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']); beforeEach(waitForAsync(() => { @@ -31,14 +43,28 @@ describe('BulkAccessBrowseComponent', () => { imports: [ NgbAccordionModule, NgbNavModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), + BulkAccessBrowseComponent, + ], + providers: [ + { provide: SelectableListService, useValue: selectableListService }, + { provide: ThemeService, useValue: getMockThemeService() }, ], - declarations: [BulkAccessBrowseComponent], - providers: [ { provide: SelectableListService, useValue: selectableListService }, ], schemas: [ - NO_ERRORS_SCHEMA - ] - }).compileComponents(); + NO_ERRORS_SCHEMA, + ], + }) + .overrideComponent(BulkAccessBrowseComponent, { + remove: { + imports: [ + PaginationComponent, + ThemedSearchComponent, + SelectableListItemControlComponent, + ListableObjectComponentLoaderComponent, + ], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -72,8 +98,8 @@ describe('BulkAccessBrowseComponent', () => { 'elementsPerPage': 5, 'totalElements': 2, 'totalPages': 1, - 'currentPage': 1 - }), [selected1, selected2]) ; + 'currentPage': 1, + }), [selected1, selected2]); const rd = createSuccessfulRemoteDataObject(list); expect(component.objectsSelected$.value).toEqual(rd); diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts index e806e729c8e..a400742f017 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts @@ -1,19 +1,48 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; - -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; - -import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; -import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; -import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + NgbAccordionModule, + NgbNavModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgxPaginationModule } from 'ngx-pagination'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; + +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; -import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { PageInfo } from '../../../core/shared/page-info.model'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-configuration.service'; import { hasValue } from '../../../shared/empty.util'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { ListableObjectComponentLoaderComponent } from '../../../shared/object-collection/shared/listable-object/listable-object-component-loader.component'; +import { SelectableListItemControlComponent } from '../../../shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; +import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { PaginationComponent } from '../../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ThemedSearchComponent } from '../../../shared/search/themed-search.component'; +import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; @Component({ selector: 'ds-bulk-access-browse', @@ -22,9 +51,24 @@ import { hasValue } from '../../../shared/empty.util'; providers: [ { provide: SEARCH_CONFIG_SERVICE, - useClass: SearchConfigurationService - } - ] + useClass: SearchConfigurationService, + }, + ], + imports: [ + PaginationComponent, + AsyncPipe, + NgbAccordionModule, + TranslateModule, + NgIf, + NgbNavModule, + ThemedSearchComponent, + BrowserOnlyPipe, + NgForOf, + NgxPaginationModule, + SelectableListItemControlComponent, + ListableObjectComponentLoaderComponent, + ], + standalone: true, }) export class BulkAccessBrowseComponent implements OnInit, OnDestroy { @@ -49,7 +93,7 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { paginationOptions$: BehaviorSubject = new BehaviorSubject(Object.assign(new PaginationComponentOptions(), { id: 'bas', pageSize: 5, - currentPage: 1 + currentPage: 1, })); /** @@ -67,20 +111,20 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { this.subs.push( this.selectableListService.getSelectableList(this.listId).pipe( distinctUntilChanged(), - map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)) - ).subscribe(this.objectsSelected$) + map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)), + ).subscribe(this.objectsSelected$), ); } pageNext() { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: this.paginationOptions$.value.currentPage + 1 + currentPage: this.paginationOptions$.value.currentPage + 1, })); } pagePrev() { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: this.paginationOptions$.value.currentPage - 1 + currentPage: this.paginationOptions$.value.currentPage - 1, })); } @@ -99,12 +143,12 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { elementsPerPage: this.paginationOptions$.value.pageSize, totalElements: list?.selection.length, totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length), - currentPage: this.paginationOptions$.value.currentPage + currentPage: this.paginationOptions$.value.currentPage, }); if (pageInfo.currentPage > pageInfo.totalPages) { pageInfo.currentPage = pageInfo.totalPages; this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: pageInfo.currentPage + currentPage: pageInfo.currentPage, })); } return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || [])); diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html index 382caf85f46..cda6b805bcc 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.html +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -1,4 +1,5 @@
+

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

@@ -9,7 +10,7 @@ -
diff --git a/src/app/access-control/bulk-access/bulk-access.component.spec.ts b/src/app/access-control/bulk-access/bulk-access.component.spec.ts index e9b253147dc..fda1db0aa3e 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.spec.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.spec.ts @@ -1,18 +1,26 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; - +import { + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { BulkAccessComponent } from './bulk-access.component'; +import { Process } from '../../process-page/processes/process.model'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Process } from '../../process-page/processes/process.model'; -import { RouterTestingModule } from '@angular/router/testing'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { ThemeService } from '../../shared/theme-support/theme.service'; +import { BulkAccessComponent } from './bulk-access.component'; +import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; describe('BulkAccessComponent', () => { let component: BulkAccessComponent; @@ -31,36 +39,43 @@ describe('BulkAccessComponent', () => { 'startDate': { 'year': 2026, 'month': 5, - 'day': 31 + 'day': 31, }, - 'endDate': null - } + 'endDate': null, + }, ], 'state': { 'item': { 'toggleStatus': true, - 'accessMode': 'replace' + 'accessMode': 'replace', }, 'bitstream': { 'toggleStatus': false, 'accessMode': '', 'changesLimit': '', - 'selectedBitstreams': [] - } - } + 'selectedBitstreams': [], + }, + }, }; const mockFile = { 'uuids': [ - '1234', '5678' + '1234', '5678', ], - 'file': { } + 'file': { }, }; - const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { - getValue: jasmine.createSpy('getValue'), - reset: jasmine.createSpy('reset') - }); + @Component({ + selector: 'ds-bulk-access-settings', + template: '', + exportAs: 'dsBulkSettings', + standalone: true, + }) + class MockBulkAccessSettingsComponent { + isFormValid = jasmine.createSpy('isFormValid').and.returnValue(false); + getValue = jasmine.createSpy('getValue'); + reset = jasmine.createSpy('reset'); + } const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; const selectableListState: SelectableListState = { id: 'test', selection }; const expectedIdList = ['1234', '5678']; @@ -71,16 +86,27 @@ describe('BulkAccessComponent', () => { await TestBed.configureTestingModule({ imports: [ RouterTestingModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), + BulkAccessComponent, ], - declarations: [ BulkAccessComponent ], providers: [ { provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock }, { provide: NotificationsService, useValue: NotificationsServiceStub }, - { provide: SelectableListService, useValue: selectableListServiceMock } + { provide: SelectableListService, useValue: selectableListServiceMock }, + { provide: ThemeService, useValue: getMockThemeService() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) + .overrideComponent(BulkAccessComponent, { + remove: { + imports: [ + BulkAccessSettingsComponent, + ], + }, + add: { + imports: [MockBulkAccessSettingsComponent], + }, + }) .compileComponents(); }); @@ -96,13 +122,12 @@ describe('BulkAccessComponent', () => { fixture.destroy(); }); - describe('when there are no elements selected', () => { + describe('when there are no elements selected and step two form is invalid', () => { beforeEach(() => { (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty)); fixture.detectChanges(); - component.settings = mockSettings; }); it('should create', () => { @@ -125,7 +150,6 @@ describe('BulkAccessComponent', () => { (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState)); fixture.detectChanges(); - component.settings = mockSettings; }); it('should create', () => { @@ -136,15 +160,29 @@ describe('BulkAccessComponent', () => { expect(component.objectsSelected$.value).toEqual(expectedIdList); }); - it('should enable the execute button when there are objects selected', () => { + it('should not enable the execute button when there are objects selected and step two form is invalid', () => { component.objectsSelected$.next(['1234']); - expect(component.canExport()).toBe(true); + expect(component.canExport()).toBe(false); }); it('should call the settings reset method when reset is called', () => { component.reset(); expect(component.settings.reset).toHaveBeenCalled(); }); + }); + describe('when there are elements selected and the step two form is valid', () => { + + beforeEach(() => { + + (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState)); + fixture.detectChanges(); + (component as any).settings.isFormValid.and.returnValue(true); + }); + + it('should enable the execute button when there are objects selected and step two form is valid', () => { + component.objectsSelected$.next(['1234']); + expect(component.canExport()).toBe(true); + }); it('should call the bulkAccessControlService executeScript method when submit is called', () => { (component.settings as any).getValue.and.returnValue(mockFormState); diff --git a/src/app/access-control/bulk-access/bulk-access.component.ts b/src/app/access-control/bulk-access/bulk-access.component.ts index 04724614cb6..e158f3c446f 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.ts @@ -1,17 +1,38 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + ViewChild, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; - -import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component'; +import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; @Component({ selector: 'ds-bulk-access', templateUrl: './bulk-access.component.html', - styleUrls: ['./bulk-access.component.scss'] + styleUrls: ['./bulk-access.component.scss'], + imports: [ + TranslateModule, + BulkAccessSettingsComponent, + BulkAccessBrowseComponent, + BtnDisabledDirective, + ], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class BulkAccessComponent implements OnInit { @@ -37,7 +58,7 @@ export class BulkAccessComponent implements OnInit { constructor( private bulkAccessControlService: BulkAccessControlService, - private selectableListService: SelectableListService + private selectableListService: SelectableListService, ) { } @@ -45,13 +66,13 @@ export class BulkAccessComponent implements OnInit { this.subs.push( this.selectableListService.getSelectableList(this.listId).pipe( distinctUntilChanged(), - map((list: SelectableListState) => this.generateIdListBySelectedElements(list)) - ).subscribe(this.objectsSelected$) + map((list: SelectableListState) => this.generateIdListBySelectedElements(list)), + ).subscribe(this.objectsSelected$), ); } canExport(): boolean { - return this.objectsSelected$.value?.length > 0; + return this.objectsSelected$.value?.length > 0 && this.settings?.isFormValid(); } /** @@ -74,12 +95,12 @@ export class BulkAccessComponent implements OnInit { const { file } = this.bulkAccessControlService.createPayloadFile({ bitstreamAccess, itemAccess, - state: settings.state + state: settings.state, }); this.bulkAccessControlService.executeScript( this.objectsSelected$.value || [], - file + file, ).subscribe(); } diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html index 01f36ef03f4..c41053874e7 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html @@ -1,13 +1,13 @@ -
- -
-
+
+
@@ -15,7 +15,7 @@
- + diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts index 14e0fdefb21..880e1f2472c 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts @@ -1,8 +1,13 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; + +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; import { BulkAccessSettingsComponent } from './bulk-access-settings.component'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; describe('BulkAccessSettingsComponent', () => { let component: BulkAccessSettingsComponent; @@ -15,36 +20,39 @@ describe('BulkAccessSettingsComponent', () => { 'startDate': { 'year': 2026, 'month': 5, - 'day': 31 + 'day': 31, }, - 'endDate': null - } + 'endDate': null, + }, ], 'state': { 'item': { 'toggleStatus': true, - 'accessMode': 'replace' + 'accessMode': 'replace', }, 'bitstream': { 'toggleStatus': false, 'accessMode': '', 'changesLimit': '', - 'selectedBitstreams': [] - } - } + 'selectedBitstreams': [], + }, + }, }; const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { getFormValue: jasmine.createSpy('getFormValue'), - reset: jasmine.createSpy('reset') + reset: jasmine.createSpy('reset'), }); beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [NgbAccordionModule, TranslateModule.forRoot()], - declarations: [BulkAccessSettingsComponent], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + imports: [NgbAccordionModule, TranslateModule.forRoot(), BulkAccessSettingsComponent], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(BulkAccessSettingsComponent, { + remove: { imports: [AccessControlFormContainerComponent] }, + }) + .compileComponents(); }); beforeEach(() => { diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts index eecc0162451..1cb0dcf5314 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts @@ -1,13 +1,25 @@ -import { Component, ViewChild } from '@angular/core'; +import { NgIf } from '@angular/common'; import { - AccessControlFormContainerComponent -} from '../../../shared/access-control-form-container/access-control-form-container.component'; + Component, + ViewChild, +} from '@angular/core'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; @Component({ selector: 'ds-bulk-access-settings', templateUrl: 'bulk-access-settings.component.html', styleUrls: ['./bulk-access-settings.component.scss'], - exportAs: 'dsBulkSettings' + exportAs: 'dsBulkSettings', + imports: [ + NgbAccordionModule, + TranslateModule, + NgIf, + AccessControlFormContainerComponent, + ], + standalone: true, }) export class BulkAccessSettingsComponent { @@ -31,4 +43,8 @@ export class BulkAccessSettingsComponent { this.controlForm.reset(); } + isFormValid() { + return this.controlForm.isValid(); + } + } 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 a07ea37df29..e6e7608ba3f 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.actions.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.actions.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; + import { EPerson } from '../../core/eperson/models/eperson.model'; import { type } from '../../shared/ngrx/type'; diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index e3a8e2c590f..b5a26533cfa 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -2,98 +2,92 @@
- +

{{labelPrefix + 'head' | translate}}

-
+
- - -
- -
-
- -
-
-
- - + + +
+ +
+
+
+ + -
-
-
-
- +
+
+ +
+ - - + + -
- - - - - - - - - - - - - - - - - -
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{ dsoNameService.getName(epersonDto.eperson) }}{{epersonDto.eperson.email}} -
- - -
-
-
+
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{ dsoNameService.getName(epersonDto.eperson) }}{{epersonDto.eperson.email}} +
+ + +
+
+
-
+
- +
diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index 4a09913862f..cd7441022cb 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -1,47 +1,76 @@ -import { Router } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + NgbModal, + NgbModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { PageInfo } from '../../core/shared/page-info.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../shared/mocks/router.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { EPeopleRegistryComponent } from './epeople-registry.component'; -import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { + EPersonMock, + EPersonMock2, +} from '../../shared/testing/eperson.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { RequestService } from '../../core/data/request.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { EPeopleRegistryComponent } from './epeople-registry.component'; +import { EPersonFormComponent } from './eperson-form/eperson-form.component'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; let fixture: ComponentFixture; - let translateService: TranslateService; let builderService: FormBuilderService; - let mockEPeople; + let mockEPeople: EPerson[]; let ePersonDataServiceStub: any; let authorizationService: AuthorizationDataService; - let modalService; - - let paginationService; + let modalService: NgbModal; + let paginationService: PaginationServiceStub; - beforeEach(waitForAsync(() => { + beforeEach(waitForAsync(async () => { jasmine.getEnv().allowRespy(true); mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { @@ -52,7 +81,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); }, getActiveEPerson(): Observable { @@ -67,7 +96,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), [result])); } if (scope === 'metadata') { @@ -76,7 +105,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); } const result = this.allEpeople.find((ePerson: EPerson) => { @@ -86,20 +115,20 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), [result])); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); }, deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { return (ePerson2.uuid !== ePerson.uuid); - }); + }); return observableOf(true); }, editEPerson(ePerson: EPerson) { @@ -113,42 +142,44 @@ describe('EPeopleRegistryComponent', () => { }, getEPeoplePageRouterLink(): string { return '/access-control/epeople'; - } + }, }; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); builderService = getMockFormBuilderService(); - translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); TestBed.configureTestingModule({ - imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), - ], - declarations: [EPeopleRegistryComponent], + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule.withRoutes([]), + TranslateModule.forRoot(), EPeopleRegistryComponent, BtnDisabledDirective], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: FormBuilderService, useValue: builderService }, - { provide: Router, useValue: new RouterStub() }, + { provide: Router, useValue: new RouterMock() }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, - { provide: PaginationService, useValue: paginationService } + { provide: PaginationService, useValue: paginationService }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(EPeopleRegistryComponent, { + remove: { + imports: [ + EPersonFormComponent, + ThemedLoadingComponent, + PaginationComponent, + ], + }, + }) + .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(EPeopleRegistryComponent); component = fixture.componentInstance; - modalService = (component as any).modalService; + modalService = TestBed.inject(NgbModal); spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); fixture.detectChanges(); }); @@ -158,10 +189,10 @@ describe('EPeopleRegistryComponent', () => { }); it('should display list of ePeople', () => { - const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + const ePeopleIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); expect(ePeopleIdsFound.length).toEqual(2); mockEPeople.map((ePerson: EPerson) => { - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === ePerson.uuid); })).toBeTruthy(); }); @@ -169,7 +200,7 @@ describe('EPeopleRegistryComponent', () => { describe('search', () => { describe('when searching with scope/query (scope metadata)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'metadata', query: EPersonMock2.name }); tick(); @@ -179,14 +210,14 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid); })).toBeTruthy(); }); }); describe('when searching with scope/query (scope email)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'email', query: EPersonMock.email }); tick(); @@ -196,43 +227,13 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid); })).toBeTruthy(); }); }); }); - describe('toggleEditEPerson', () => { - describe('when you click on first edit eperson button', () => { - beforeEach(fakeAsync(() => { - const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton')); - editButtons[0].triggerEventHandler('click', { - preventDefault: () => {/**/ - } - }); - tick(); - fixture.detectChanges(); - })); - - it('editEPerson form is toggled', () => { - const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); - ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) { - expect(component.isEPersonFormShown).toEqual(false); - } else { - expect(component.isEPersonFormShown).toEqual(true); - } - - }); - }); - - it('EPerson search section is hidden', () => { - expect(fixture.debugElement.query(By.css('#search'))).toBeNull(); - }); - }); - }); - describe('deleteEPerson', () => { describe('when you click on first delete eperson button', () => { let ePeopleIdsFoundBeforeDelete; @@ -242,7 +243,7 @@ describe('EPeopleRegistryComponent', () => { const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton')); deleteButtons[0].triggerEventHandler('click', { preventDefault: () => {/**/ - } + }, }); tick(); fixture.detectChanges(); @@ -258,19 +259,12 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('delete EPerson button when the isAuthorized returns false', () => { - let ePeopleDeleteButton; - beforeEach(() => { - spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); - component.initialisePage(); - fixture.detectChanges(); - }); - it('should be disabled', () => { - ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); - ePeopleDeleteButton.forEach((deleteButton: DebugElement) => { - expect(deleteButton.nativeElement.disabled).toBe(true); - }); - }); + it('should hide delete EPerson button when the isAuthorized returns false', () => { + spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); + component.initialisePage(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('#epeople tr td div button.delete-button'))).toBeNull(); }); }); diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index fb045ebb883..6b62a13ecf1 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -1,31 +1,86 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormBuilder } from '@angular/forms'; -import { Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + ReactiveFormsModule, + UntypedFormBuilder, +} from '@angular/forms'; +import { + Router, + RouterModule, +} from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + Observable, + Subscription, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { + getAllSucceededRemoteData, + getFirstCompletedRemoteData, +} from '../../core/shared/operators'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue } from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { getAllSucceededRemoteData, getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { RequestService } from '../../core/data/request.service'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { NoContent } from '../../core/shared/NoContent.model'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { + getEPersonEditRoute, + getEPersonsRoute, +} from '../access-control-routing-paths'; +import { EPersonFormComponent } from './eperson-form/eperson-form.component'; @Component({ selector: 'ds-epeople-registry', templateUrl: './epeople-registry.component.html', + imports: [ + TranslateModule, + RouterModule, + AsyncPipe, + NgIf, + EPersonFormComponent, + ReactiveFormsModule, + ThemedLoadingComponent, + PaginationComponent, + NgClass, + NgForOf, + ], + standalone: true, }) /** * A component used for managing all existing epeople within the repository. @@ -45,6 +100,8 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ ePeopleDto$: BehaviorSubject> = new BehaviorSubject>({} as any); + activeEPerson$: Observable; + /** * An observable for the pageInfo, needed to pass to the pagination component */ @@ -61,14 +118,9 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'elp', pageSize: 5, - currentPage: 1 + currentPage: 1, }); - /** - * Whether or not to show the EPerson form - */ - isEPersonFormShown: boolean; - // The search form searchForm; @@ -114,26 +166,21 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ initialisePage() { this.searching$.next(true); - this.isEPersonFormShown = false; - this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - if (eperson != null && eperson.id) { - this.isEPersonFormShown = true; - } - })); + this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); + this.activeEPerson$ = this.epersonService.getActiveEPerson(); this.subs.push(this.ePeople$.pipe( switchMap((epeople: PaginatedList) => { if (epeople.pageInfo.totalElements > 0) { - return combineLatest([...epeople.page.map((eperson: EPerson) => { + return combineLatest(epeople.page.map((eperson: EPerson) => { return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( map((authorized) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); epersonDtoModel.ableToDelete = authorized; epersonDtoModel.eperson = eperson; return epersonDtoModel; - }) + }), ); - })]).pipe(map((dtos: EpersonDtoModel[]) => { + })).pipe(map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epeople.pageInfo, dtos); })); } else { @@ -157,78 +204,44 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { } this.findListOptionsSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((findListOptions) => { - const query: string = data.query; - const scope: string = data.scope; - if (query != null && this.currentSearchQuery !== query) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { - queryParamsHandling: 'merge' - }); - this.currentSearchQuery = query; - this.paginationService.resetPage(this.config.id); - } - if (scope != null && this.currentSearchScope !== scope) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { - queryParamsHandling: 'merge' - }); - this.currentSearchScope = scope; - this.paginationService.resetPage(this.config.id); - - } - return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { - currentPage: findListOptions.currentPage, - elementsPerPage: findListOptions.pageSize + const query: string = data.query; + const scope: string = data.scope; + if (query != null && this.currentSearchQuery !== query) { + void this.router.navigate([getEPersonsRoute()], { + queryParamsHandling: 'merge', }); + this.currentSearchQuery = query; + this.paginationService.resetPage(this.config.id); + } + if (scope != null && this.currentSearchScope !== scope) { + void this.router.navigate([getEPersonsRoute()], { + queryParamsHandling: 'merge', + }); + this.currentSearchScope = scope; + this.paginationService.resetPage(this.config.id); + } + return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + currentPage: findListOptions.currentPage, + elementsPerPage: findListOptions.pageSize, + }); + }, ), getAllSucceededRemoteData(), ).subscribe((peopleRD) => { - this.ePeople$.next(peopleRD.payload); - this.pageInfoState$.next(peopleRD.payload.pageInfo); - } + this.ePeople$.next(peopleRD.payload); + this.pageInfoState$.next(peopleRD.payload.pageInfo); + }, ); } - /** - * Checks whether the given EPerson is active (being edited) - * @param eperson - */ - isActive(eperson: EPerson): Observable { - return this.getActiveEPerson().pipe( - map((activeEPerson) => eperson === activeEPerson) - ); - } - - /** - * Gets the active eperson (being edited) - */ - getActiveEPerson(): Observable { - return this.epersonService.getActiveEPerson(); - } - - /** - * Start editing the selected EPerson - * @param ePerson - */ - toggleEditEPerson(ePerson: EPerson) { - this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => { - if (ePerson === activeEPerson) { - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - } else { - this.epersonService.editEPerson(ePerson); - this.isEPersonFormShown = true; - } - }); - this.scrollToTop(); - } - /** * Deletes EPerson, show notification on success/failure & updates EPeople list */ deleteEPerson(ePerson: EPerson) { if (hasValue(ePerson.id)) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = ePerson; + modalRef.componentInstance.name = this.dsoNameService.getName(ePerson); modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; @@ -240,9 +253,9 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { if (hasValue(ePerson.id)) { this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { if (restResponse.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)})); + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(ePerson) })); } else { - this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage })); } }); } @@ -264,16 +277,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } - scrollToTop() { - (function smoothscroll() { - const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; - if (currentScroll > 0) { - window.requestAnimationFrame(smoothscroll); - window.scrollTo(0, currentScroll - (currentScroll / 8)); - } - })(); - } - /** * Reset all input-fields to be empty and search all search */ @@ -281,23 +284,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.searchForm.patchValue({ query: '', }); - this.search({query: ''}); + this.search({ query: '' }); } - /** - * This method will set everything to stale, which will cause the lists on this page to update. - */ - reset(): void { - this.epersonService.getBrowseEndpoint().pipe( - take(1), - switchMap((href: string) => { - return this.requestService.setStaleByHrefSubstring(href).pipe( - take(1), - ); - }) - ).subscribe(()=>{ - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }); + getEditEPeoplePage(id: string): string { + return getEPersonEditRoute(id); } } diff --git a/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts index 7158acc79b4..6bee3f84e21 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts @@ -1,6 +1,12 @@ -import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from './epeople-registry.actions'; -import { ePeopleRegistryReducer, EPeopleRegistryState } from './epeople-registry.reducers'; import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction, +} from './epeople-registry.actions'; +import { + ePeopleRegistryReducer, + EPeopleRegistryState, +} from './epeople-registry.reducers'; const initialState: EPeopleRegistryState = { editEPerson: null, diff --git a/src/app/access-control/epeople-registry/epeople-registry.reducers.ts b/src/app/access-control/epeople-registry/epeople-registry.reducers.ts index 1e0319f3ba4..3bab6769e12 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.reducers.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.reducers.ts @@ -2,7 +2,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPeopleRegistryAction, EPeopleRegistryActionTypes, - EPeopleRegistryEditEPersonAction + EPeopleRegistryEditEPersonAction, } from './epeople-registry.actions'; /** @@ -30,13 +30,13 @@ export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegi case EPeopleRegistryActionTypes.EDIT_EPERSON: { return Object.assign({}, state, { - editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson + editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson, }); } case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: { return Object.assign({}, state, { - editEPerson: null + editEPerson: null, }); } diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index 3aa488b4953..3aa4d66b051 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 @@ -1,89 +1,98 @@ -
+
+
+
- -

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

-
+
- -

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

-
+ +

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

+
- -
- -
-
- -
-
- - -
- -
+ +

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

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

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

- + -
- - - - - - - - - - - - - - - -
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}
{{group.id}} - - {{ dsoNameService.getName(group) }} - - {{ dsoNameService.getName(undefined) }}
-
+ + +
+ + + + + + + + + + + + + + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}
{{group.id}} + + {{ dsoNameService.getName(group) }} + + + {{ dsoNameService.getName((group.object | async)?.payload) }} +
+
-
+
-
diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index fb911e709c4..c5c94073777 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -1,36 +1,71 @@ -import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, + RouterModule, +} from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../core/auth/auth.service'; +import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { PaginationService } from '../../../core/pagination/pagination.service'; import { PageInfo } from '../../../core/shared/page-info.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; +import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { EPeopleRegistryComponent } from '../epeople-registry.component'; -import { EPersonFormComponent } from './eperson-form.component'; -import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock'; +import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; -import { AuthService } from '../../../core/auth/auth.service'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; import { AuthServiceStub } from '../../../shared/testing/auth-service.stub'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { RequestService } from '../../../core/data/request.service'; -import { PaginationService } from '../../../core/pagination/pagination.service'; +import { + EPersonMock, + EPersonMock2, +} from '../../../shared/testing/eperson.mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { RouterStub } from '../../../shared/testing/router.stub'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { HasNoValuePipe } from '../../../shared/utils/has-no-value.pipe'; +import { EPeopleRegistryComponent } from '../epeople-registry.component'; +import { EPersonFormComponent } from './eperson-form.component'; import { ValidateEmailNotTaken } from './validators/email-taken.validator'; -import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -43,6 +78,8 @@ describe('EPersonFormComponent', () => { let authorizationService: AuthorizationDataService; let groupsDataService: GroupDataService; let epersonRegistrationService: EpersonRegistrationService; + let route: ActivatedRouteStub; + let router: RouterStub; let paginationService; @@ -53,9 +90,6 @@ describe('EPersonFormComponent', () => { ePersonDataServiceStub = { activeEPerson: null, allEpeople: mockEPeople, - getEPeople(): Observable>> { - return createSuccessfulRemoteDataObject$(buildPaginatedList(null, this.allEpeople)); - }, getActiveEPerson(): Observable { return observableOf(this.activeEPerson); }, @@ -106,70 +140,73 @@ describe('EPersonFormComponent', () => { }, getEPersonByEmail(email): Observable> { return createSuccessfulRemoteDataObject$(null); - } + }, + findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig[]): Observable> { + return createSuccessfulRemoteDataObject$(null); + }, }; builderService = Object.assign(getMockFormBuilderService(),{ createFormGroup(formModel, options = null) { const controls = {}; formModel.forEach( model => { - model.parent = parent; - const controlModel = model; - const controlState = { value: controlModel.value, disabled: controlModel.disabled }; - const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); - controls[model.id] = new UntypedFormControl(controlState, controlOptions); + model.parent = parent; + const controlModel = model; + const controlState = { value: controlModel.value, disabled: controlModel.disabled }; + const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); + controls[model.id] = new UntypedFormControl(controlState, controlOptions); }); return new UntypedFormGroup(controls, options); }, createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { return { - validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, + validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, }; }, getValidators(validatorsConfig) { - return this.getValidatorFns(validatorsConfig); + return this.getValidatorFns(validatorsConfig); }, getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) { let validatorFns = []; if (this.isObject(validatorsConfig)) { - validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { - const validatorConfigValue = validatorsConfig[validatorConfigKey]; - if (this.isValidatorDescriptor(validatorConfigValue)) { - const descriptor = validatorConfigValue; - return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); - } - return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); - }); + validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { + const validatorConfigValue = validatorsConfig[validatorConfigKey]; + if (this.isValidatorDescriptor(validatorConfigValue)) { + const descriptor = validatorConfigValue; + return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); + } + return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); + }); } return validatorFns; }, getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) { let validatorFn; if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators - validatorFn = Validators[validatorName]; + validatorFn = Validators[validatorName]; } else { // Custom Validators - if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { - validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); - } else if (validatorsToken) { - validatorFn = validatorsToken.find(validator => validator.name === validatorName); - } + if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { + validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); + } else if (validatorsToken) { + validatorFn = validatorsToken.find(validator => validator.name === validatorName); + } } if (validatorFn === undefined) { // throw when no validator could be resolved - throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); + throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); } if (validatorArgs !== null) { - return validatorFn(validatorArgs); + return validatorFn(validatorArgs); } return validatorFn; - }, + }, isValidatorDescriptor(value) { - if (this.isObject(value)) { - return value.hasOwnProperty('name') && value.hasOwnProperty('args'); - } - return false; + if (this.isObject(value)) { + return value.hasOwnProperty('name') && value.hasOwnProperty('args'); + } + return false; }, isObject(value) { return typeof value === 'object' && value !== null; - } + }, }); authService = new AuthServiceStub(); authorizationService = jasmine.createSpyObj('authorizationService', { @@ -178,20 +215,19 @@ describe('EPersonFormComponent', () => { }); groupsDataService = jasmine.createSpyObj('groupsDataService', { findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), - getGroupRegistryRouterLink: '' + getGroupRegistryRouterLink: '', }); paginationService = new PaginationServiceStub(); + route = new ActivatedRouteStub(); + router = new RouterStub(); TestBed.configureTestingModule({ - imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BtnDisabledDirective, BrowserModule, + RouterModule.forRoot([]), + TranslateModule.forRoot(), + EPersonFormComponent, + HasNoValuePipe, ], - declarations: [EPersonFormComponent], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: GroupDataService, useValue: groupsDataService }, @@ -200,16 +236,22 @@ describe('EPersonFormComponent', () => { { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: PaginationService, useValue: paginationService }, - { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}, + { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, - EPeopleRegistryComponent + { provide: ActivatedRoute, useValue: route }, + { provide: Router, useValue: router }, + EPeopleRegistryComponent, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(EPersonFormComponent, { + remove: { imports: [ ThemedLoadingComponent, PaginationComponent,FormComponent] }, + }) + .compileComponents(); })); epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - registerEmail: createSuccessfulRemoteDataObject$(null) + registerEmail: createSuccessfulRemoteDataObject$(null), }); beforeEach(() => { @@ -223,37 +265,13 @@ describe('EPersonFormComponent', () => { }); describe('check form validation', () => { - let firstName; - let lastName; - let email; - let canLogIn; - let requireCertificate; + let canLogIn: boolean; + let requireCertificate: boolean; - let expected; beforeEach(() => { - firstName = 'testName'; - lastName = 'testLastName'; - email = 'testEmail@test.com'; canLogIn = false; requireCertificate = false; - expected = Object.assign(new EPerson(), { - metadata: { - 'eperson.firstname': [ - { - value: firstName - } - ], - 'eperson.lastname': [ - { - value: lastName - }, - ], - }, - email: email, - canLogIn: canLogIn, - requireCertificate: requireCertificate, - }); spyOn(component.submitForm, 'emit'); component.canLogIn.value = canLogIn; component.requireCertificate.value = requireCertificate; @@ -263,24 +281,18 @@ describe('EPersonFormComponent', () => { fixture.detectChanges(); }); describe('firstName, lastName and email should be required', () => { - it('form should be invalid because the firstName is required', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.firstName.valid).toBeFalse(); - expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); - }); - })); - it('form should be invalid because the lastName is required', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.lastName.valid).toBeFalse(); - expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); - }); - })); - it('form should be invalid because the email is required', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.email.valid).toBeFalse(); - expect(component.formGroup.controls.email.errors.required).toBeTrue(); - }); - })); + it('form should be invalid because the firstName is required', () => { + expect(component.formGroup.controls.firstName.valid).toBeFalse(); + expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); + }); + it('form should be invalid because the lastName is required', () => { + expect(component.formGroup.controls.lastName.valid).toBeFalse(); + expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); + }); + it('form should be invalid because the email is required', () => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.required).toBeTrue(); + }); }); describe('after inserting information firstName,lastName and email not required', () => { @@ -290,24 +302,18 @@ describe('EPersonFormComponent', () => { component.formGroup.controls.email.setValue('test@test.com'); fixture.detectChanges(); }); - it('firstName should be valid because the firstName is set', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.firstName.valid).toBeTrue(); - expect(component.formGroup.controls.firstName.errors).toBeNull(); - }); - })); - it('lastName should be valid because the lastName is set', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.lastName.valid).toBeTrue(); - expect(component.formGroup.controls.lastName.errors).toBeNull(); - }); - })); - it('email should be valid because the email is set', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.email.valid).toBeTrue(); - expect(component.formGroup.controls.email.errors).toBeNull(); - }); - })); + it('firstName should be valid because the firstName is set', () => { + expect(component.formGroup.controls.firstName.valid).toBeTrue(); + expect(component.formGroup.controls.firstName.errors).toBeNull(); + }); + it('lastName should be valid because the lastName is set', () => { + expect(component.formGroup.controls.lastName.valid).toBeTrue(); + expect(component.formGroup.controls.lastName.errors).toBeNull(); + }); + it('email should be valid because the email is set', () => { + expect(component.formGroup.controls.email.valid).toBeTrue(); + expect(component.formGroup.controls.email.errors).toBeNull(); + }); }); @@ -316,12 +322,10 @@ describe('EPersonFormComponent', () => { component.formGroup.controls.email.setValue('test@test'); fixture.detectChanges(); }); - it('email should not be valid because the email pattern', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.email.valid).toBeFalse(); - expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); - }); - })); + it('email should not be valid because the email pattern', () => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); + }); }); describe('after already utilized email', () => { @@ -329,29 +333,25 @@ describe('EPersonFormComponent', () => { const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{ getEPersonByEmail(): Observable> { return createSuccessfulRemoteDataObject$(EPersonMock); - } + }, }); component.formGroup.controls.email.setValue('test@test.com'); component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson)); fixture.detectChanges(); }); - it('email should not be valid because email is already taken', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.email.valid).toBeFalse(); - expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); - }); - })); + it('email should not be valid because email is already taken', () => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); + }); }); - - - }); + describe('when submitting the form', () => { let firstName; let lastName; let email; - let canLogIn; + let canLogIn: boolean; let requireCertificate; let expected; @@ -366,12 +366,12 @@ describe('EPersonFormComponent', () => { metadata: { 'eperson.firstname': [ { - value: firstName - } + value: firstName, + }, ], 'eperson.lastname': [ { - value: lastName + value: lastName, }, ], }, @@ -380,6 +380,7 @@ describe('EPersonFormComponent', () => { requireCertificate: requireCertificate, }); spyOn(component.submitForm, 'emit'); + component.ngOnInit(); component.firstName.value = firstName; component.lastName.value = lastName; component.email.value = email; @@ -393,11 +394,9 @@ describe('EPersonFormComponent', () => { fixture.detectChanges(); }); - it('should emit a new eperson using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected); - }); - })); + it('should emit a new eperson using the correct values', () => { + expect(component.submitForm.emit).toHaveBeenCalledWith(expected); + }); }); describe('with an active eperson', () => { @@ -409,30 +408,36 @@ describe('EPersonFormComponent', () => { metadata: { 'eperson.firstname': [ { - value: firstName - } + value: firstName, + }, ], 'eperson.lastname': [ { - value: lastName + value: lastName, }, ], }, email: email, canLogIn: canLogIn, requireCertificate: requireCertificate, - _links: undefined + _links: { + groups: { + href: '', + }, + self: { + href: '', + }, + }, }); spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId)); + component.ngOnInit(); component.onSubmit(); fixture.detectChanges(); }); - it('should emit the existing eperson using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); - }); - })); + it('should emit the existing eperson using the correct values', () => { + expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); + }); }); }); @@ -443,7 +448,7 @@ describe('EPersonFormComponent', () => { spyOn(authService, 'impersonate').and.callThrough(); ePersonId = 'testEPersonId'; component.epersonInitial = Object.assign(new EPerson(), { - id: ePersonId + id: ePersonId, }); component.impersonate(); }); @@ -473,34 +478,31 @@ describe('EPersonFormComponent', () => { }); describe('delete', () => { - - let ePersonId; let eperson: EPerson; let modalService; beforeEach(() => { spyOn(authService, 'impersonate').and.callThrough(); - ePersonId = 'testEPersonId'; eperson = EPersonMock; component.epersonInitial = eperson; component.canDelete$ = observableOf(true); spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson)); modalService = (component as any).modalService; spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); + component.ngOnInit(); fixture.detectChanges(); - }); - it('the delete button should be active if the eperson can be deleted', () => { + it('the delete button should be visible if the ePerson can be deleted', () => { const deleteButton = fixture.debugElement.query(By.css('.delete-button')); - expect(deleteButton.nativeElement.disabled).toBe(false); + expect(deleteButton).not.toBeNull(); }); - it('the delete button should be disabled if the eperson cannot be deleted', () => { + it('the delete button should be hidden if the ePerson cannot be deleted', () => { component.canDelete$ = observableOf(false); fixture.detectChanges(); const deleteButton = fixture.debugElement.query(By.css('.delete-button')); - expect(deleteButton.nativeElement.disabled).toBe(true); + expect(deleteButton).toBeNull(); }); it('should call the epersonFormComponent delete when clicked on the button', () => { @@ -515,7 +517,8 @@ describe('EPersonFormComponent', () => { // ePersonDataServiceStub.activeEPerson = eperson; spyOn(component.epersonService, 'deleteEPerson').and.returnValue(createSuccessfulRemoteDataObject$('No Content', 204)); const deleteButton = fixture.debugElement.query(By.css('.delete-button')); - expect(deleteButton.nativeElement.disabled).toBe(false); + expect(deleteButton.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(deleteButton.nativeElement.classList.contains('disabled')).toBeFalse(); deleteButton.triggerEventHandler('click', null); fixture.detectChanges(); expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson); @@ -531,7 +534,7 @@ describe('EPersonFormComponent', () => { ePersonEmail = 'person.email@4science.it'; component.epersonInitial = Object.assign(new EPerson(), { id: ePersonId, - email: ePersonEmail + email: ePersonEmail, }); component.resetPassword(); }); 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 d009d560589..60d221e1160 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 @@ -1,47 +1,99 @@ -import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { + AsyncPipe, + NgFor, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; +import { + ActivatedRoute, + Router, + RouterLink, +} from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicCheckboxModel, DynamicFormControlModel, DynamicFormLayout, - DynamicInputModel + DynamicInputModel, } from '@ng-dynamic-forms/core'; -import { TranslateService } from '@ngx-translate/core'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { debounceTime, finalize, map, switchMap, take } from 'rxjs/operators'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; +import { + debounceTime, + finalize, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { AuthService } from '../../../core/auth/auth.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { Group } from '../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { NoContent } from '../../../core/shared/NoContent.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getRemoteDataPayload + getRemoteDataPayload, } from '../../../core/shared/operators'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Registration } from '../../../core/shared/registration.model'; +import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; +import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue } from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { 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 { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { RequestService } from '../../../core/data/request.service'; -import { NoContent } from '../../../core/shared/NoContent.model'; -import { PaginationService } from '../../../core/pagination/pagination.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { HasNoValuePipe } from '../../../shared/utils/has-no-value.pipe'; +import { getEPersonsRoute } from '../../access-control-routing-paths'; import { ValidateEmailNotTaken } from './validators/email-taken.validator'; -import { Registration } from '../../../core/shared/registration.model'; -import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; -import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-eperson-form', templateUrl: './eperson-form.component.html', + imports: [ + FormComponent, + NgIf, + NgFor, + AsyncPipe, + TranslateModule, + ThemedLoadingComponent, + PaginationComponent, + RouterLink, + HasNoValuePipe, + BtnDisabledDirective, + ], + standalone: true, }) /** * A form used for creating and editing EPeople @@ -81,28 +133,28 @@ export class EPersonFormComponent implements OnInit, OnDestroy { formLayout: DynamicFormLayout = { firstName: { grid: { - host: 'row' - } + host: 'row', + }, }, lastName: { grid: { - host: 'row' - } + host: 'row', + }, }, email: { grid: { - host: 'row' - } + host: 'row', + }, }, canLogIn: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, requireCertificate: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, }; @@ -137,6 +189,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ canImpersonate$: Observable; + /** + * The current {@link EPerson} + */ + activeEPerson$: Observable; + /** * List of subscriptions */ @@ -145,7 +202,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { /** * A list of all the groups this EPerson is a member of */ - groups: Observable>>; + groups$: Observable>>; + + /** + * The pagination of the {@link groups$} list. + */ + groupsPageInfoState$: Observable; /** * Pagination config used to display the list of groups @@ -153,7 +215,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'gem', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** @@ -194,8 +256,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy { public requestService: RequestService, private epersonRegistrationService: EpersonRegistrationService, public dsoNameService: DSONameService, + protected route: ActivatedRoute, + protected router: Router, ) { - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + } + + ngOnInit() { + this.activeEPerson$ = this.epersonService.getActiveEPerson(); + this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => { this.epersonInitial = eperson; if (hasValue(eperson)) { this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); @@ -203,9 +271,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.submitLabel = 'form.submit'; } })); - } - - ngOnInit() { this.initialisePage(); } @@ -213,124 +278,121 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * This method will initialise the page */ initialisePage() { - - observableCombineLatest([ - this.translateService.get(`${this.messagePrefix}.firstName`), - this.translateService.get(`${this.messagePrefix}.lastName`), - this.translateService.get(`${this.messagePrefix}.email`), - this.translateService.get(`${this.messagePrefix}.canLogIn`), - this.translateService.get(`${this.messagePrefix}.requireCertificate`), - this.translateService.get(`${this.messagePrefix}.emailHint`), - ]).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => { - this.firstName = new DynamicInputModel({ - id: 'firstName', - label: firstName, - name: 'firstName', - validators: { - required: null, - }, - required: true, + if (this.route.snapshot.params.id) { + this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData) => { + this.epersonService.editEPerson(ePersonRD.payload); + })); + } + this.firstName = new DynamicInputModel({ + id: 'firstName', + label: this.translateService.instant(`${this.messagePrefix}.firstName`), + name: 'firstName', + validators: { + required: null, + }, + required: true, + }); + this.lastName = new DynamicInputModel({ + id: 'lastName', + label: this.translateService.instant(`${this.messagePrefix}.lastName`), + name: 'lastName', + validators: { + required: null, + }, + required: true, + }); + this.email = new DynamicInputModel({ + id: 'email', + label: this.translateService.instant(`${this.messagePrefix}.email`), + name: 'email', + validators: { + required: null, + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', + }, + required: true, + errorMessages: { + emailTaken: 'error.validation.emailTaken', + pattern: 'error.validation.NotValidEmail', + }, + hint: this.translateService.instant(`${this.messagePrefix}.emailHint`), + }); + this.canLogIn = new DynamicCheckboxModel( + { + id: 'canLogIn', + label: this.translateService.instant(`${this.messagePrefix}.canLogIn`), + name: 'canLogIn', + value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true), }); - this.lastName = new DynamicInputModel({ - id: 'lastName', - label: lastName, - name: 'lastName', - validators: { - required: null, - }, - required: true, + this.requireCertificate = new DynamicCheckboxModel( + { + id: 'requireCertificate', + label: this.translateService.instant(`${this.messagePrefix}.requireCertificate`), + name: 'requireCertificate', + value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false), }); - this.email = new DynamicInputModel({ - id: 'email', - label: email, - name: 'email', - validators: { - required: null, - pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', - }, - required: true, - errorMessages: { - emailTaken: 'error.validation.emailTaken', - pattern: 'error.validation.NotValidEmail' - }, - hint: emailHint + this.formModel = [ + this.firstName, + this.lastName, + this.email, + this.canLogIn, + this.requireCertificate, + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => { + if (eperson != null) { + this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, { + currentPage: 1, + elementsPerPage: this.config.pageSize, + }, undefined, undefined, followLink('object')); + } + this.formGroup.patchValue({ + firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', + lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', + email: eperson != null ? eperson.email : '', + canLogIn: eperson != null ? eperson.canLogIn : true, + requireCertificate: eperson != null ? eperson.requireCertificate : false, }); - this.canLogIn = new DynamicCheckboxModel( - { - id: 'canLogIn', - label: canLogIn, - name: 'canLogIn', - value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true) - }); - this.requireCertificate = new DynamicCheckboxModel( - { - id: 'requireCertificate', - label: requireCertificate, - name: 'requireCertificate', - value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false) + + if (eperson === null && !!this.formGroup.controls.email) { + this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService)); + this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.changeDetectorRef.detectChanges(); }); - this.formModel = [ - this.firstName, - this.lastName, - this.email, - this.canLogIn, - this.requireCertificate, - ]; - this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + } + })); + + this.groups$ = this.activeEPerson$.pipe( + switchMap((eperson) => { + return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { + currentPage: 1, + elementsPerPage: this.config.pageSize, + })]); + }), + switchMap(([eperson, findListOptions]) => { if (eperson != null) { - this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, { - currentPage: 1, - elementsPerPage: this.config.pageSize - }); + return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); } - this.formGroup.patchValue({ - firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', - lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', - email: eperson != null ? eperson.email : '', - canLogIn: eperson != null ? eperson.canLogIn : true, - requireCertificate: eperson != null ? eperson.requireCertificate : false - }); + return observableOf(undefined); + }), + ); - if (eperson === null && !!this.formGroup.controls.email) { - this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService)); - this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => { - this.changeDetectorRef.detectChanges(); - }); - } - })); + this.groupsPageInfoState$ = this.groups$.pipe( + map(groupsRD => groupsRD.payload.pageInfo), + ); - const activeEPerson$ = this.epersonService.getActiveEPerson(); - - this.groups = activeEPerson$.pipe( - switchMap((eperson) => { - return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { - currentPage: 1, - elementsPerPage: this.config.pageSize - })]); - }), - switchMap(([eperson, findListOptions]) => { - if (eperson != null) { - return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); - } - return observableOf(undefined); - }) - ); - - this.canImpersonate$ = activeEPerson$.pipe( - switchMap((eperson) => { - if (hasValue(eperson)) { - return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self); - } else { - return observableOf(false); - } - }) - ); - this.canDelete$ = activeEPerson$.pipe( - switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) - ); - this.canReset$ = observableOf(true); - }); + this.canImpersonate$ = this.activeEPerson$.pipe( + switchMap((eperson) => { + if (hasValue(eperson)) { + return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self); + } else { + return observableOf(false); + } + }), + ); + this.canDelete$ = this.activeEPerson$.pipe( + switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)), + ); + this.canReset$ = observableOf(true); } /** @@ -339,6 +401,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onCancel() { this.epersonService.cancelEditEPerson(); this.cancelForm.emit(); + void this.router.navigate([getEPersonsRoute()]); } /** @@ -348,18 +411,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Emit the updated/created eperson using the EventEmitter submitForm */ onSubmit() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe( + this.activeEPerson$.pipe(take(1)).subscribe( (ePerson: EPerson) => { const values = { metadata: { 'eperson.firstname': [ { - value: this.firstName.value - } + value: this.firstName.value, + }, ], 'eperson.lastname': [ { - value: this.lastName.value + value: this.lastName.value, }, ], }, @@ -372,7 +435,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } else { this.editEPerson(ePerson, values); } - } + }, ); } @@ -385,11 +448,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { const response = this.epersonService.create(ePersonToCreate); response.pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) })); this.submitForm.emit(ePersonToCreate); + this.epersonService.clearEPersonRequests(); + void this.router.navigateByUrl(getEPersonsRoute()); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) })); this.cancelForm.emit(); @@ -409,12 +474,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { metadata: { 'eperson.firstname': [ { - value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')) - } + value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')), + }, ], 'eperson.lastname': [ { - value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')) + value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')), }, ], }, @@ -429,6 +494,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) })); this.submitForm.emit(editedEperson); + void this.router.navigateByUrl(getEPersonsRoute()); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) })); this.cancelForm.emit(); @@ -447,7 +513,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onPageChange(event) { this.updateGroups({ currentPage: event, - elementsPerPage: this.config.pageSize + elementsPerPage: this.config.pageSize, }); } @@ -464,11 +530,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * It'll either show a success or error message depending on whether the delete was successful or not. */ delete(): void { - this.epersonService.getActiveEPerson().pipe( + this.activeEPerson$.pipe( take(1), switchMap((eperson: EPerson) => { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = eperson; + modalRef.componentInstance.name = this.dsoNameService.getName(eperson); modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; @@ -483,18 +549,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.canDelete$ = observableOf(false); return this.epersonService.deleteEPerson(eperson).pipe( getFirstCompletedRemoteData(), - map((restResponse: RemoteData) => ({ restResponse, eperson })) + map((restResponse: RemoteData) => ({ restResponse, eperson })), ); } else { return observableOf(null); } }), - finalize(() => this.canDelete$ = observableOf(true)) + finalize(() => this.canDelete$ = observableOf(true)), ); - }) + }), ).subscribe(({ restResponse, eperson }: { restResponse: RemoteData | null, eperson: EPerson }) => { if (restResponse?.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) })); + void this.router.navigate([getEPersonsRoute()]); } else { this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`); } @@ -518,14 +585,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy { if (hasValue(this.epersonInitial.email)) { this.epersonRegistrationService.registerEmail(this.epersonInitial.email, null, TYPE_REQUEST_FORGOT).pipe(getFirstCompletedRemoteData()) .subscribe((response: RemoteData) => { - if (response.hasSucceeded) { - this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'), - this.translateService.get('forgot-email.form.success.content', {email: this.epersonInitial.email})); - } else { - this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'), - this.translateService.get('forgot-email.form.error.content', {email: this.epersonInitial.email})); - } + if (response.hasSucceeded) { + this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'), + this.translateService.get('forgot-email.form.success.content', { email: this.epersonInitial.email })); + } else { + this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'), + this.translateService.get('forgot-email.form.error.content', { email: this.epersonInitial.email })); } + }, ); } } @@ -541,16 +608,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } } - /** - * This method will ensure that the page gets reset and that the cache is cleared - */ - reset() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { - this.requestService.removeByHrefSubstring(eperson.self); - }); - this.initialisePage(); - } - /** * Checks for the given ePerson if there is already an ePerson in the system with that email * and shows notification if this is the case @@ -561,13 +618,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { // Relevant message for email in use this.subs.push(this.epersonService.searchByScope('email', ePerson.email, { currentPage: 1, - elementsPerPage: 0 + elementsPerPage: 0, }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) .subscribe((list: PaginatedList) => { if (list.totalElements > 0) { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', { name: this.dsoNameService.getName(ePerson), - email: ePerson.email + email: ePerson.email, })); } })); @@ -577,8 +634,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Update the list of groups by fetching it from the rest api or cache */ private updateGroups(options) { - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options); + this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => { + this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, options); })); } } diff --git a/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts index 5153abae7c5..2a689c0d729 100644 --- a/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts +++ b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts @@ -1,9 +1,12 @@ -import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { + AbstractControl, + ValidationErrors, +} from '@angular/forms'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; -import { getFirstSucceededRemoteData, } from '../../../../core/shared/operators'; +import { getFirstSucceededRemoteData } from '../../../../core/shared/operators'; export class ValidateEmailNotTaken { @@ -17,8 +20,8 @@ export class ValidateEmailNotTaken { .pipe( getFirstSucceededRemoteData(), map(res => { - return !!res.payload ? { emailTaken: true } : null; - }) + return res.payload ? { emailTaken: true } : null; + }), ); }; } diff --git a/src/app/access-control/epeople-registry/eperson-resolver.service.ts b/src/app/access-control/epeople-registry/eperson-resolver.service.ts new file mode 100644 index 00000000000..6c9d7347f73 --- /dev/null +++ b/src/app/access-control/epeople-registry/eperson-resolver.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { RemoteData } from '../../core/data/remote-data'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ResolvedAction } from '../../core/resolving/resolver.actions'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; + +export const EPERSON_EDIT_FOLLOW_LINKS: FollowLinkConfig[] = [ + followLink('groups'), +]; + +/** + * This class represents a resolver that requests a specific {@link EPerson} before the route is activated + */ +@Injectable({ + providedIn: 'root', +}) +export class EPersonResolver { + + constructor( + protected ePersonService: EPersonDataService, + protected store: Store, + ) { + } + + /** + * Method for resolving a {@link EPerson} based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns `Observable<>` Emits the found {@link EPerson} based on the parameters in the current + * route, or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const ePersonRD$: Observable> = this.ePersonService.findById(route.params.id, + true, + false, + ...EPERSON_EDIT_FOLLOW_LINKS, + ).pipe( + getFirstCompletedRemoteData(), + ); + + ePersonRD$.subscribe((ePersonRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, ePersonRD.payload)); + }); + + return ePersonRD$; + } + +} diff --git a/src/app/access-control/group-registry/group-form/group-form.component.html b/src/app/access-control/group-registry/group-form/group-form.component.html index 64a16c3aedd..7e8c1ed1b4c 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.html +++ b/src/app/access-control/group-registry/group-form/group-form.component.html @@ -2,14 +2,14 @@
-
+
-

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

+

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

- -

+ +

> {{messagePrefix + '.head.edit' | translate}} -

+

- - - + + + + + + +
-
-
-
- -
- - - - + +
+ +
+ +
diff --git a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index f8c5f3cd870..b7e6a35d4e1 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -1,60 +1,91 @@ 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 { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { ActivatedRoute, Router } from '@angular/router'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, + 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'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Observable, of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { DSOChangeAnalyzer } from '../../../core/data/dso-change-analyzer.service'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { Group } from '../../../core/eperson/models/group.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NoContent } from '../../../core/shared/NoContent.model'; import { PageInfo } from '../../../core/shared/page-info.model'; import { UUIDService } from '../../../core/shared/uuid.service'; +import { XSRFService } from '../../../core/xsrf/xsrf.service'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { ContextHelpDirective } from '../../../shared/context-help.directive'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { GroupMock, GroupMock2 } from '../../../shared/testing/group-mock'; -import { GroupFormComponent } from './group-form.component'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { FormComponent } from '../../../shared/form/form.component'; +import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; -import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; -import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mock'; import { RouterMock } from '../../../shared/mocks/router.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { + GroupMock, + GroupMock2, +} from '../../../shared/testing/group-mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { Operation } from 'fast-json-patch'; +import { GroupFormComponent } from './group-form.component'; +import { MembersListComponent } from './members-list/members-list.component'; +import { SubgroupsListComponent } from './subgroup-list/subgroups-list.component'; import { ValidateGroupExists } from './validators/group-exists.validator'; -import { NoContent } from '../../../core/shared/NoContent.model'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; describe('GroupFormComponent', () => { let component: GroupFormComponent; let fixture: ComponentFixture; - let translateService: TranslateService; let builderService: FormBuilderService; let ePersonDataServiceStub: any; let groupsDataServiceStub: any; let dsoDataServiceStub: any; let authorizationService: AuthorizationDataService; let notificationService: NotificationsServiceStub; - let router; + let router: RouterMock; + let route: ActivatedRouteStub; - let groups; - let groupName; - let groupDescription; - let expected; + let groups: Group[]; + let groupName: string; + let groupDescription: string; + let expected: Group; beforeEach(waitForAsync(() => { groups = [GroupMock, GroupMock2]; @@ -65,10 +96,19 @@ describe('GroupFormComponent', () => { metadata: { 'dc.description': [ { - value: groupDescription - } + value: groupDescription, + }, ], }, + object: createSuccessfulRemoteDataObject$(undefined), + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); ePersonDataServiceStub = {}; groupsDataServiceStub = { @@ -105,7 +145,14 @@ describe('GroupFormComponent', () => { create(group: Group): Observable> { this.allGroups = [...this.allGroups, group]; this.createdGroup = Object.assign({}, group, { - _links: { self: { href: 'group-selflink' } } + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); return createSuccessfulRemoteDataObject$(this.createdGroup); }, @@ -114,92 +161,88 @@ describe('GroupFormComponent', () => { }, getGroupEditPageRouterLinkWithID(id: string) { return `group-edit-page-for-${id}`; - } + }, }; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); dsoDataServiceStub = { findByHref(href: string): Observable> { return null; - } + }, }; builderService = Object.assign(getMockFormBuilderService(),{ createFormGroup(formModel, options = null) { const controls = {}; formModel.forEach( model => { - model.parent = parent; - const controlModel = model; - const controlState = { value: controlModel.value, disabled: controlModel.disabled }; - const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); - controls[model.id] = new UntypedFormControl(controlState, controlOptions); + model.parent = parent; + const controlModel = model; + const controlState = { value: controlModel.value, disabled: controlModel.disabled }; + const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); + controls[model.id] = new UntypedFormControl(controlState, controlOptions); }); return new UntypedFormGroup(controls, options); }, createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { return { - validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, + validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, }; }, getValidators(validatorsConfig) { - return this.getValidatorFns(validatorsConfig); + return this.getValidatorFns(validatorsConfig); }, getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) { let validatorFns = []; if (this.isObject(validatorsConfig)) { - validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { - const validatorConfigValue = validatorsConfig[validatorConfigKey]; - if (this.isValidatorDescriptor(validatorConfigValue)) { - const descriptor = validatorConfigValue; - return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); - } - return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); - }); + validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { + const validatorConfigValue = validatorsConfig[validatorConfigKey]; + if (this.isValidatorDescriptor(validatorConfigValue)) { + const descriptor = validatorConfigValue; + return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); + } + return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); + }); } return validatorFns; }, getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) { let validatorFn; if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators - validatorFn = Validators[validatorName]; + validatorFn = Validators[validatorName]; } else { // Custom Validators - if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { - validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); - } else if (validatorsToken) { - validatorFn = validatorsToken.find(validator => validator.name === validatorName); - } + if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { + validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); + } else if (validatorsToken) { + validatorFn = validatorsToken.find(validator => validator.name === validatorName); + } } if (validatorFn === undefined) { // throw when no validator could be resolved - throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); + throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); } if (validatorArgs !== null) { - return validatorFn(validatorArgs); + return validatorFn(validatorArgs); } return validatorFn; - }, + }, isValidatorDescriptor(value) { - if (this.isObject(value)) { - return value.hasOwnProperty('name') && value.hasOwnProperty('args'); - } - return false; + if (this.isObject(value)) { + return value.hasOwnProperty('name') && value.hasOwnProperty('args'); + } + return false; }, isObject(value) { return typeof value === 'object' && value !== null; - } + }, }); - translateService = getMockTranslateService(); router = new RouterMock(); + route = new ActivatedRouteStub(); notificationService = new NotificationsServiceStub(); + return TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), + TranslateModule.forRoot(), + GroupFormComponent, ], - declarations: [GroupFormComponent], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: EPersonDataService, useValue: ePersonDataServiceStub }, @@ -211,18 +254,26 @@ describe('GroupFormComponent', () => { { provide: HttpClient, useValue: {} }, { provide: ObjectCacheService, useValue: {} }, { provide: UUIDService, useValue: {} }, + { provide: XSRFService, useValue: {} }, { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, - { - provide: ActivatedRoute, - useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) } - }, + { provide: ActivatedRoute, useValue: route }, { provide: Router, useValue: router }, { provide: AuthorizationDataService, useValue: authorizationService }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(GroupFormComponent, { + remove: { imports: [ + FormComponent, + AlertComponent, + ContextHelpDirective, + MembersListComponent, + SubgroupsListComponent, + ] }, + }) + .compileComponents(); })); beforeEach(() => { @@ -234,8 +285,8 @@ describe('GroupFormComponent', () => { describe('when submitting the form', () => { beforeEach(() => { spyOn(component.submitForm, 'emit'); - component.groupName.value = groupName; - component.groupDescription.value = groupDescription; + component.groupName.setValue(groupName); + component.groupDescription.setValue(groupDescription); }); describe('without active Group', () => { beforeEach(() => { @@ -243,75 +294,92 @@ describe('GroupFormComponent', () => { fixture.detectChanges(); }); - it('should emit a new group using the correct values', (async () => { - await fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected); - }); + it('should emit a new group using the correct values', (() => { + expect(component.submitForm.emit).toHaveBeenCalledWith(jasmine.objectContaining({ + name: groupName, + metadata: { + 'dc.description': [ + { + value: groupDescription, + }, + ], + }, + })); })); }); + describe('with active Group', () => { - let expected2; + let expected2: Group; beforeEach(() => { expected2 = Object.assign(new Group(), { name: 'newGroupName', metadata: { 'dc.description': [ { - value: groupDescription - } + value: groupDescription, + }, ], }, + object: createSuccessfulRemoteDataObject$(undefined), + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf(expected)); spyOn(groupsDataServiceStub, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(expected2)); - component.groupName.value = 'newGroupName'; - component.onSubmit(); - fixture.detectChanges(); + component.ngOnInit(); }); it('should edit with name and description operations', () => { + component.groupName.setValue('newGroupName'); + component.onSubmit(); const operations = [{ op: 'add', path: '/metadata/dc.description', - value: 'testDescription' + value: 'testDescription', }, { op: 'replace', path: '/name', - value: 'newGroupName' + value: 'newGroupName', }]; expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); it('should edit with description operations', () => { - component.groupName.value = null; + component.groupName.setValue(null); component.onSubmit(); - fixture.detectChanges(); const operations = [{ op: 'add', path: '/metadata/dc.description', - value: 'testDescription' + value: 'testDescription', }]; expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); it('should edit with name operations', () => { - component.groupDescription.value = null; + component.groupName.setValue('newGroupName'); + component.groupDescription.setValue(null); component.onSubmit(); - fixture.detectChanges(); const operations = [{ op: 'replace', path: '/name', - value: 'newGroupName' + value: 'newGroupName', }]; expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); - it('should emit the existing group using the correct new values', (async () => { - await fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected2); - }); - })); + it('should emit the existing group using the correct new values', () => { + component.onSubmit(); + expect(component.submitForm.emit).toHaveBeenCalledWith(expected2); + }); + it('should emit success notification', () => { + component.onSubmit(); expect(notificationService.success).toHaveBeenCalled(); }); }); @@ -326,11 +394,8 @@ describe('GroupFormComponent', () => { describe('check form validation', () => { - let groupCommunity; - beforeEach(() => { groupName = 'testName'; - groupCommunity = 'testgroupCommunity'; groupDescription = 'testgroupDescription'; expected = Object.assign(new Group(), { @@ -338,12 +403,21 @@ describe('GroupFormComponent', () => { metadata: { 'dc.description': [ { - value: groupDescription - } + value: groupDescription, + }, ], }, + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); spyOn(component.submitForm, 'emit'); + spyOn(dsoDataServiceStub, 'findByHref').and.returnValue(observableOf(expected)); fixture.detectChanges(); component.initialisePage(); @@ -376,7 +450,7 @@ describe('GroupFormComponent', () => { const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{ searchGroups(query: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected])); - } + }, }); component.formGroup.controls.groupName.setValue('testName'); component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup)); @@ -393,21 +467,20 @@ describe('GroupFormComponent', () => { }); describe('delete', () => { - let deleteButton; - - beforeEach(() => { - component.initialisePage(); + let deleteButton: HTMLButtonElement; + beforeEach(async () => { + spyOn(groupsDataServiceStub, 'delete').and.callThrough(); + component.activeGroup$ = observableOf({ + id: 'active-group', + permanent: false, + } as Group); component.canEdit$ = observableOf(true); - component.groupBeingEdited = { - permanent: false - } as Group; + + component.initialisePage(); fixture.detectChanges(); deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement; - - spyOn(groupsDataServiceStub, 'delete').and.callThrough(); - spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' })); }); describe('if confirmed via modal', () => { diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 693e283b4a0..444fffb0d3d 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 @@ -1,57 +1,105 @@ -import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { + AbstractControl, + UntypedFormGroup, +} from '@angular/forms'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormControlModel, DynamicFormLayout, DynamicInputModel, - DynamicTextAreaModel + DynamicTextAreaModel, } from '@ng-dynamic-forms/core'; -import { TranslateService } from '@ngx-translate/core'; import { - ObservedValueOf, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; +import { combineLatest as observableCombineLatest, Observable, - of as observableOf, Subscription, } from 'rxjs'; -import { catchError, map, switchMap, take, filter, debounceTime } from 'rxjs/operators'; +import { + debounceTime, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../../environments/environment'; import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; 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'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { RequestService } from '../../../core/data/request.service'; -import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { Group } from '../../../core/eperson/models/group.model'; import { Collection } from '../../../core/shared/collection.model'; import { Community } from '../../../core/shared/community.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { NoContent } from '../../../core/shared/NoContent.model'; import { - getRemoteDataPayload, - getFirstSucceededRemoteData, + getAllCompletedRemoteData, getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload + getFirstSucceededRemoteData, + getRemoteDataPayload, } from '../../../core/shared/operators'; +import { AlertComponent } from '../../../shared/alert/alert.component'; import { AlertType } from '../../../shared/alert/alert-type'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; -import { hasValue, isNotEmpty, hasValueOperator } from '../../../shared/empty.util'; +import { ContextHelpDirective } from '../../../shared/context-help.directive'; +import { + hasValue, + hasValueOperator, + isNotEmpty, +} from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { NoContent } from '../../../core/shared/NoContent.model'; -import { Operation } from 'fast-json-patch'; +import { + getGroupEditRoute, + getGroupsRoute, +} from '../../access-control-routing-paths'; +import { MembersListComponent } from './members-list/members-list.component'; +import { SubgroupsListComponent } from './subgroup-list/subgroups-list.component'; import { ValidateGroupExists } from './validators/group-exists.validator'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { environment } from '../../../../environments/environment'; @Component({ selector: 'ds-group-form', - templateUrl: './group-form.component.html' + templateUrl: './group-form.component.html', + imports: [ + FormComponent, + AlertComponent, + NgIf, + AsyncPipe, + TranslateModule, + ContextHelpDirective, + MembersListComponent, + SubgroupsListComponent, + ], + standalone: true, }) /** * A form used for creating and editing groups @@ -68,9 +116,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { /** * Dynamic models for the inputs of form */ - groupName: DynamicInputModel; - groupCommunity: DynamicInputModel; - groupDescription: DynamicTextAreaModel; + groupName: AbstractControl; + groupCommunity: AbstractControl; + groupDescription: AbstractControl; /** * A list of all dynamic input models @@ -83,13 +131,13 @@ export class GroupFormComponent implements OnInit, OnDestroy { formLayout: DynamicFormLayout = { groupName: { grid: { - host: 'row' - } + host: 'row', + }, }, groupDescription: { grid: { - host: 'row' - } + host: 'row', + }, }, }; @@ -114,20 +162,29 @@ export class GroupFormComponent implements OnInit, OnDestroy { subs: Subscription[] = []; /** - * Group currently being edited + * Observable whether or not the logged in user is allowed to delete the Group & doesn't have a linked object (community / collection linked to workspace group + */ + canEdit$: Observable; + + /** + * The current {@link Group} */ - groupBeingEdited: Group; + activeGroup$: Observable; /** - * Observable whether or not the logged in user is allowed to delete the Group & doesn't have a linked object (community / collection linked to workspace group + * The current {@link Group}'s linked {@link Community}/{@link Collection} */ - canEdit$: Observable; + activeGroupLinkedDSO$: Observable; + + /** + * Link to the current {@link Group}'s {@link Community}/{@link Collection} edit role tab + */ + linkedEditRolesRoute$: Observable; /** * The AlertType enumeration - * @type {AlertType} */ - public AlertTypeEnum = AlertType; + public readonly AlertType = AlertType; /** * Subscription to email field value change @@ -137,124 +194,121 @@ export class GroupFormComponent implements OnInit, OnDestroy { constructor( public groupDataService: GroupDataService, - private ePersonDataService: EPersonDataService, - private dSpaceObjectDataService: DSpaceObjectDataService, - private formBuilderService: FormBuilderService, - private translateService: TranslateService, - private notificationsService: NotificationsService, - private route: ActivatedRoute, + protected dSpaceObjectDataService: DSpaceObjectDataService, + protected formBuilderService: FormBuilderService, + protected translateService: TranslateService, + protected notificationsService: NotificationsService, + protected route: ActivatedRoute, protected router: Router, - private authorizationService: AuthorizationDataService, - private modalService: NgbModal, + protected authorizationService: AuthorizationDataService, + protected modalService: NgbModal, public requestService: RequestService, protected changeDetectorRef: ChangeDetectorRef, public dsoNameService: DSONameService, ) { } - ngOnInit() { + ngOnInit(): void { + if (this.route.snapshot.params.groupId !== 'newGroup') { + this.setActiveGroup(this.route.snapshot.params.groupId); + } + this.activeGroup$ = this.groupDataService.getActiveGroup(); + this.activeGroupLinkedDSO$ = this.getActiveGroupLinkedDSO(); + this.linkedEditRolesRoute$ = this.getLinkedEditRolesRoute(); + this.canEdit$ = this.activeGroupLinkedDSO$.pipe( + switchMap((dso: DSpaceObject) => { + if (hasValue(dso)) { + return [false]; + } else { + return this.activeGroup$.pipe( + hasValueOperator(), + switchMap((group: Group) => this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self)), + ); + } + }), + ); this.initialisePage(); } initialisePage() { - this.subs.push(this.route.params.subscribe((params) => { - if (params.groupId !== 'newGroup') { - this.setActiveGroup(params.groupId); - } - })); - this.canEdit$ = this.groupDataService.getActiveGroup().pipe( - hasValueOperator(), - switchMap((group: Group) => { - return observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined), - this.hasLinkedDSO(group), - (isAuthorized: ObservedValueOf>, hasLinkedDSO: ObservedValueOf>) => { - return isAuthorized && !hasLinkedDSO; - }); - }) - ); - observableCombineLatest( - this.translateService.get(`${this.messagePrefix}.groupName`), - this.translateService.get(`${this.messagePrefix}.groupCommunity`), - this.translateService.get(`${this.messagePrefix}.groupDescription`) - ).subscribe(([groupName, groupCommunity, groupDescription]) => { - this.groupName = new DynamicInputModel({ - id: 'groupName', - label: groupName, - name: 'groupName', - validators: { - required: null, - }, - required: true, - }); - this.groupCommunity = new DynamicInputModel({ - id: 'groupCommunity', - label: groupCommunity, - name: 'groupCommunity', - required: false, - readOnly: true, - }); - this.groupDescription = new DynamicTextAreaModel({ - id: 'groupDescription', - label: groupDescription, - name: 'groupDescription', - required: false, - spellCheck: environment.form.spellCheck, + const groupNameModel = new DynamicInputModel({ + id: 'groupName', + label: this.translateService.instant(`${this.messagePrefix}.groupName`), + name: 'groupName', + validators: { + required: null, + }, + required: true, + }); + const groupCommunityModel = new DynamicInputModel({ + id: 'groupCommunity', + label: this.translateService.instant(`${this.messagePrefix}.groupCommunity`), + name: 'groupCommunity', + required: false, + readOnly: true, + }); + const groupDescriptionModel = new DynamicTextAreaModel({ + id: 'groupDescription', + label: this.translateService.instant(`${this.messagePrefix}.groupDescription`), + name: 'groupDescription', + required: false, + spellCheck: environment.form.spellCheck, + }); + this.formModel = [ + groupNameModel, + groupDescriptionModel, + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.groupName = this.formGroup.get('groupName'); + this.groupDescription = this.formGroup.get('groupDescription'); + + if (hasValue(this.groupName)) { + this.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); + this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.changeDetectorRef.detectChanges(); }); - this.formModel = [ - this.groupName, - this.groupDescription, - ]; - this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - - if (!!this.formGroup.controls.groupName) { - this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); - this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { - this.changeDetectorRef.detectChanges(); - }); - } - - this.subs.push( - observableCombineLatest( - this.groupDataService.getActiveGroup(), - this.canEdit$, - this.groupDataService.getActiveGroup() - .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))) - ).subscribe(([activeGroup, canEdit, linkedObject]) => { + } - if (activeGroup != null) { + this.subs.push( + observableCombineLatest([ + this.activeGroup$, + this.canEdit$, + this.activeGroupLinkedDSO$, + ]).subscribe(([activeGroup, canEdit, linkedObject]) => { - // Disable group name exists validator - this.formGroup.controls.groupName.clearAsyncValidators(); + if (activeGroup != null) { - this.groupBeingEdited = activeGroup; + // Disable group name exists validator + this.formGroup.controls.groupName.clearAsyncValidators(); - if (linkedObject?.name) { - this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); - this.formGroup.patchValue({ - groupName: activeGroup.name, - groupCommunity: linkedObject?.name ?? '', - groupDescription: activeGroup.firstMetadataValue('dc.description'), - }); - } else { - this.formModel = [ - this.groupName, - this.groupDescription, - ]; - this.formGroup.patchValue({ - groupName: activeGroup.name, - groupDescription: activeGroup.firstMetadataValue('dc.description'), - }); + if (isNotEmpty(linkedObject?.name)) { + if (!this.formGroup.controls.groupCommunity) { + this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, groupCommunityModel); + this.groupDescription = this.formGroup.get('groupCommunity'); } - setTimeout(() => { - if (!canEdit || activeGroup.permanent) { - this.formGroup.disable(); - } - }, 200); + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupCommunity: linkedObject?.name ?? '', + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); + } else { + this.formModel = [ + groupNameModel, + groupDescriptionModel, + ]; + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); } - }) - ); - }); + if (!canEdit || activeGroup.permanent) { + this.formGroup.disable(); + } else { + this.formGroup.enable(); + } + } + }), + ); } /** @@ -263,7 +317,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { onCancel() { this.groupDataService.cancelEditGroup(); this.cancelForm.emit(); - this.router.navigate([this.groupDataService.getGroupRegistryRouterLink()]); + void this.router.navigate([getGroupsRoute()]); } /** @@ -273,25 +327,22 @@ export class GroupFormComponent implements OnInit, OnDestroy { * Emit the updated/created eperson using the EventEmitter submitForm */ onSubmit() { - this.groupDataService.getActiveGroup().pipe(take(1)).subscribe( - (group: Group) => { - const values = { + this.activeGroup$.pipe(take(1)).subscribe((group: Group) => { + if (group === null) { + this.createNewGroup({ name: this.groupName.value, metadata: { 'dc.description': [ { - value: this.groupDescription.value - } - ] + value: this.groupDescription.value, + }, + ], }, - }; - if (group === null) { - this.createNewGroup(values); - } else { - this.editGroup(group); - } + }); + } else { + this.editGroup(group); } - ); + }); } /** @@ -301,7 +352,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { createNewGroup(values) { const groupToCreate = Object.assign(new Group(), values); this.groupDataService.create(groupToCreate).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.created.success', { name: groupToCreate.name })); @@ -310,7 +361,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { const groupSelfLink = rd.payload._links.self.href; this.setActiveGroupWithLink(groupSelfLink); this.groupDataService.clearGroupsRequests(); - this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(rd.payload.uuid)); + void this.router.navigateByUrl(getGroupEditRoute(rd.payload.uuid)); } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name })); @@ -330,7 +381,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { // Relevant message for group name in use this.subs.push(this.groupDataService.searchGroups(group.name, { currentPage: 1, - elementsPerPage: 0 + elementsPerPage: 0, }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) .subscribe((list: PaginatedList) => { if (list.totalElements > 0) { @@ -352,7 +403,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { operations = [...operations, { op: 'add', path: '/metadata/dc.description', - value: this.groupDescription.value + value: this.groupDescription.value, }]; } @@ -360,12 +411,12 @@ export class GroupFormComponent implements OnInit, OnDestroy { operations = [...operations, { op: 'replace', path: '/name', - value: this.groupName.value + value: this.groupName.value, }]; } this.groupDataService.patch(group, operations).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: this.dsoNameService.getName(rd.payload) })); @@ -397,7 +448,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { * @param groupSelfLink SelfLink of group to set as active */ setActiveGroupWithLink(groupSelfLink: string) { - this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { + this.activeGroup$.pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup === null) { this.groupDataService.cancelEditGroup(); this.groupDataService.findByHref(groupSelfLink, false, false, followLink('subgroups'), followLink('epersons'), followLink('object')) @@ -416,9 +467,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { * It'll either show a success or error message depending on whether the delete was successful or not. */ delete() { - this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { + this.activeGroup$.pipe(take(1)).subscribe((group: Group) => { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = group; + modalRef.componentInstance.name = this.dsoNameService.getName(group); modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel'; @@ -453,59 +504,45 @@ export class GroupFormComponent implements OnInit, OnDestroy { this.groupDataService.cancelEditGroup(); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - if ( hasValue(this.groupNameValueChangeSubscribe) ) { + if (hasValue(this.groupNameValueChangeSubscribe)) { this.groupNameValueChangeSubscribe.unsubscribe(); } } /** - * Check if group has a linked object (community or collection linked to a workflow group) - * @param group - */ - hasLinkedDSO(group: Group): Observable { - if (hasValue(group) && hasValue(group._links.object.href)) { - return this.getLinkedDSO(group).pipe( - map((rd: RemoteData) => { - return hasValue(rd) && hasValue(rd.payload); - }), - catchError(() => observableOf(false)), - ); - } - } - - /** - * Get group's linked object if it has one (community or collection linked to a workflow group) - * @param group + * Get the active {@link Group}'s linked object if it has one ({@link Community} or {@link Collection} linked to a + * workflow group) */ - getLinkedDSO(group: Group): Observable> { - if (hasValue(group) && hasValue(group._links.object.href)) { - if (group.object === undefined) { - return this.dSpaceObjectDataService.findByHref(group._links.object.href); - } - return group.object; - } + getActiveGroupLinkedDSO(): Observable { + return this.activeGroup$.pipe( + hasValueOperator(), + switchMap((group: Group) => { + if (group.object === undefined) { + return this.dSpaceObjectDataService.findByHref(group._links.object.href); + } + return group.object; + }), + getAllCompletedRemoteData(), + getRemoteDataPayload(), + ); } /** - * Get the route to the edit roles tab of the group's linked object (community or collection linked to a workflow group) if it has one - * @param group + * Get the route to the edit roles tab of the active {@link Group}'s linked object (community or collection linked + * to a workflow group) if it has one */ - getLinkedEditRolesRoute(group: Group): Observable { - if (hasValue(group) && hasValue(group._links.object.href)) { - return this.getLinkedDSO(group).pipe( - map((rd: RemoteData) => { - if (hasValue(rd) && hasValue(rd.payload)) { - const dso = rd.payload; - switch ((dso as any).type) { - case Community.type.value: - return getCommunityEditRolesRoute(rd.payload.id); - case Collection.type.value: - return getCollectionEditRolesRoute(rd.payload.id); - } - } - }) - ); - } + getLinkedEditRolesRoute(): Observable { + return this.activeGroupLinkedDSO$.pipe( + hasValueOperator(), + map((dso: DSpaceObject) => { + switch ((dso as any).type) { + case Community.type.value: + return getCommunityEditRolesRoute(dso.id); + case Collection.type.value: + return getCollectionEditRolesRoute(dso.id); + } + }), + ); } } diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index cc9bf34d644..d289d036bb4 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -1,51 +1,16 @@ -

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

+

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

- +

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

-
-
- -
-
-
- - - - -
-
-
- -
-
- -
- +
@@ -55,33 +20,31 @@ - - + +
{{messagePrefix + '.table.id' | translate}}
{{ePerson.eperson.id}}
{{epersonDTO.eperson.id}} - - {{ dsoNameService.getName(ePerson.eperson) }} + + {{ dsoNameService.getName(epersonDTO.eperson) }} - {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
- {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} + {{messagePrefix + '.table.email' | translate}}: {{ epersonDTO.eperson.email ? epersonDTO.eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ epersonDTO.eperson.netid ? epersonDTO.eperson.netid : '-' }}
- - -
@@ -93,23 +56,49 @@

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

+ - +
+
+ + + + +
+
+
+ +
+ + +
- +
@@ -119,32 +108,23 @@

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

- - + + { +import { Community } from '../../core/shared/community.model'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { createCollectionPageGuard } from './create-collection-page.guard'; + +describe('createCollectionPageGuard', () => { describe('canActivate', () => { - let guard: CreateCollectionPageGuard; + let guard: any; let router; let communityDataServiceStub: any; @@ -20,46 +24,46 @@ describe('CreateCollectionPageGuard', () => { } else if (id === 'error-id') { return createFailedRemoteDataObject$('not found', 404); } - } + }, }; router = new RouterMock(); - guard = new CreateCollectionPageGuard(router, communityDataServiceStub); + guard = createCollectionPageGuard; }); it('should return true when the parent ID resolves to a community', () => { - guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined) + guard({ queryParams: { parent: 'valid-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); it('should return false when no parent ID has been provided', () => { - guard.canActivate({ queryParams: { } } as any, undefined) + guard({ queryParams: { } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); it('should return false when the parent ID does not resolve to a community', () => { - guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined) + guard({ queryParams: { parent: 'invalid-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); it('should return false when the parent ID resolves to an error response', () => { - guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined) + guard({ queryParams: { parent: 'error-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); }); diff --git a/src/app/collection-page/create-collection-page/create-collection-page.guard.ts b/src/app/collection-page/create-collection-page/create-collection-page.guard.ts index ca84231912b..099578c550e 100644 --- a/src/app/collection-page/create-collection-page/create-collection-page.guard.ts +++ b/src/app/collection-page/create-collection-page/create-collection-page.guard.ts @@ -1,43 +1,52 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + tap, +} from 'rxjs/operators'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { map, tap } from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; /** - * Prevent creation of a collection without a parent community provided - * @class CreateCollectionPageGuard + * True when either a parent ID query parameter has been provided and the parent ID resolves to a valid parent community + * Reroutes to a 404 page when the page cannot be activated */ -@Injectable() -export class CreateCollectionPageGuard implements CanActivate { - public constructor(private router: Router, private communityService: CommunityDataService) { +export const createCollectionPageGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + communityService: CommunityDataService = inject(CommunityDataService), + router: Router = inject(Router), +): Observable => { + const parentID = route.queryParams.parent; + if (hasNoValue(parentID)) { + router.navigate(['/404']); + return observableOf(false); } - - /** - * True when either a parent ID query parameter has been provided and the parent ID resolves to a valid parent community - * Reroutes to a 404 page when the page cannot be activated - * @method canActivate - */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const parentID = route.queryParams.parent; - if (hasNoValue(parentID)) { - this.router.navigate(['/404']); - return observableOf(false); - } - return this.communityService.findById(parentID) - .pipe( - getFirstCompletedRemoteData(), - map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), - tap((isValid: boolean) => { - if (!isValid) { - this.router.navigate(['/404']); - } - }) + return communityService.findById(parentID) + .pipe( + getFirstCompletedRemoteData(), + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + router.navigate(['/404']); + } + }), ); - } -} +}; + diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html index ba54bbabd59..0cdba00f039 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 @@ -2,16 +2,16 @@
- +

{{ 'collection.delete.head' | translate}}

{{ 'collection.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}

- -
diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts index 9fc88932d07..8a99397b918 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts @@ -1,17 +1,21 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { DeleteCollectionPageComponent } from './delete-collection-page.component'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { RequestService } from '../../core/data/request.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DeleteCollectionPageComponent } from './delete-collection-page.component'; describe('DeleteCollectionPageComponent', () => { let comp: DeleteCollectionPageComponent; @@ -19,16 +23,15 @@ describe('DeleteCollectionPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [DeleteCollectionPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, DeleteCollectionPageComponent], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: CollectionDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, - { provide: RequestService, useValue: {} } + { provide: RequestService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts index 0a1b87e58b3..acc716b52a7 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 @@ -1,11 +1,24 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -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 { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; -import { TranslateService } from '@ngx-translate/core'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; +import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { VarDirective } from '../../shared/utils/var.directive'; /** * Component that represents the page where a user can delete an existing Collection @@ -13,7 +26,15 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-delete-collection', styleUrls: ['./delete-collection-page.component.scss'], - templateUrl: './delete-collection-page.component.html' + templateUrl: './delete-collection-page.component.html', + imports: [ + TranslateModule, + AsyncPipe, + NgIf, + VarDirective, + BtnDisabledDirective, + ], + standalone: true, }) export class DeleteCollectionPageComponent extends DeleteComColPageComponent { protected frontendURL = '/collections/'; diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts index 04da8bbcd92..a64a8a273d2 100644 --- a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts @@ -1,25 +1,76 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + of as observableOf, + of, +} from 'rxjs'; +import { Community } from '../../../core/shared/community.model'; +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { CollectionAccessControlComponent } from './collection-access-control.component'; -xdescribe('CollectionAccessControlComponent', () => { +describe('CollectionAccessControlComponent', () => { let component: CollectionAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }], + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID', + }, + }, + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity), + }), + }, + }, + }; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CollectionAccessControlComponent ] + imports: [CollectionAccessControlComponent], + providers: [{ + provide: ActivatedRoute, useValue: routeStub, + }], }) - .compileComponents(); + .overrideComponent(CollectionAccessControlComponent, { + remove: { + imports: [AccessControlFormContainerComponent], + }, + }) + .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts index 4192fe5a9a3..809fdcede87 100644 --- a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts @@ -1,15 +1,30 @@ -import { Component, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { Community } from '../../../core/shared/community.model'; -import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs/operators'; import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; @Component({ selector: 'ds-collection-access-control', templateUrl: './collection-access-control.component.html', styleUrls: ['./collection-access-control.component.scss'], + imports: [ + AccessControlFormContainerComponent, + NgIf, + AsyncPipe, + ], + standalone: true, }) export class CollectionAccessControlComponent implements OnInit { itemRD$: Observable>; @@ -18,7 +33,7 @@ export class CollectionAccessControlComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.parent.parent.data.pipe( - map((data) => data.dso) + map((data) => data.dso), ).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts index c8e529443a4..3ee3f1c6b04 100644 --- a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts @@ -1,15 +1,22 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; - import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { CollectionAuthorizationsComponent } from './collection-authorizations.component'; import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ResourcePoliciesComponent } from '../../../shared/resource-policies/resource-policies.component'; +import { CollectionAuthorizationsComponent } from './collection-authorizations.component'; describe('CollectionAuthorizationsComponent', () => { let comp: CollectionAuthorizationsComponent; @@ -19,8 +26,8 @@ describe('CollectionAuthorizationsComponent', () => { uuid: 'collection', id: 'collection', _links: { - self: { href: 'collection-selflink' } - } + self: { href: 'collection-selflink' }, + }, }); const collectionRD = createSuccessfulRemoteDataObject(collection); @@ -29,25 +36,29 @@ describe('CollectionAuthorizationsComponent', () => { parent: { parent: { data: observableOf({ - dso: collectionRD - }) - } - } + dso: collectionRD, + }), + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - CommonModule + CommonModule, + CollectionAuthorizationsComponent, ], - declarations: [CollectionAuthorizationsComponent], providers: [ { provide: ActivatedRoute, useValue: routeStub }, ChangeDetectorRef, CollectionAuthorizationsComponent, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); + }) + .overrideComponent(CollectionAuthorizationsComponent, { + remove: { imports: [ResourcePoliciesComponent] }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts index d1b59a0c903..31824b7be81 100644 --- a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts @@ -1,15 +1,27 @@ -import { Component, OnInit } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; - import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { ResourcePoliciesComponent } from '../../../shared/resource-policies/resource-policies.component'; @Component({ selector: 'ds-collection-authorizations', templateUrl: './collection-authorizations.component.html', + imports: [ + ResourcePoliciesComponent, + AsyncPipe, + ], + standalone: true, }) /** * Component that handles the Collection Authorizations @@ -27,7 +39,7 @@ export class CollectionAuthorizationsComponent imp * @param {ActivatedRoute} route */ constructor( - private route: ActivatedRoute + private route: ActivatedRoute, ) { } diff --git a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html index 38c9d22f4ed..4fbe60d6b17 100644 --- a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html +++ b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html @@ -1,5 +1,5 @@
-

{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}

+

{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}

diff --git a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts index 2cf25734e12..b10131e4f40 100644 --- a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts @@ -1,12 +1,21 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { CollectionCurateComponent } from './collection-curate.component'; import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { Collection } from '../../../core/shared/collection.model'; -import { ActivatedRoute } from '@angular/router'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { CurationFormComponent } from '../../../curation-form/curation-form.component'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { CollectionCurateComponent } from './collection-curate.component'; describe('CollectionCurateComponent', () => { let comp: CollectionCurateComponent; @@ -17,31 +26,34 @@ describe('CollectionCurateComponent', () => { let dsoNameService; const collection = Object.assign(new Collection(), { - metadata: {'dc.title': ['Collection Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} + metadata: { 'dc.title': ['Collection Name'], 'dc.identifier.uri': [ { value: '123456789/1' }] }, }); beforeEach(waitForAsync(() => { routeStub = { parent: { data: observableOf({ - dso: createSuccessfulRemoteDataObject(collection) - }) - } + dso: createSuccessfulRemoteDataObject(collection), + }), + }, }; dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: 'Collection Name' + getName: 'Collection Name', }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [CollectionCurateComponent], + imports: [TranslateModule.forRoot(), CollectionCurateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: DSONameService, useValue: dsoNameService} + { provide: ActivatedRoute, useValue: routeStub }, + { provide: DSONameService, useValue: dsoNameService }, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }).compileComponents(); + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(CollectionCurateComponent, { + remove: { imports: [CurationFormComponent] }, + }) + .compileComponents(); })); beforeEach(() => { @@ -58,7 +70,7 @@ describe('CollectionCurateComponent', () => { }); it('should contain the collection information provided in the route', () => { comp.dsoRD$.subscribe((value) => { - expect(value.payload.handle + expect(value.payload.handle, ).toEqual('123456789/1'); }); comp.collectionName$.subscribe((value) => { diff --git a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts index e20f229cd64..370506e4732 100644 --- a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts @@ -1,10 +1,21 @@ -import { Component } from '@angular/core'; -import { filter, map, take } from 'rxjs/operators'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Observable } from 'rxjs'; +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from '../../../core/data/remote-data'; import { Collection } from '../../../core/shared/collection.model'; +import { CurationFormComponent } from '../../../curation-form/curation-form.component'; import { hasValue } from '../../../shared/empty.util'; /** @@ -13,8 +24,14 @@ import { hasValue } from '../../../shared/empty.util'; @Component({ selector: 'ds-collection-curate', templateUrl: './collection-curate.component.html', + imports: [ + CurationFormComponent, + TranslateModule, + AsyncPipe, + ], + standalone: true, }) -export class CollectionCurateComponent { +export class CollectionCurateComponent implements OnInit { dsoRD$: Observable>; collectionName$: Observable; @@ -34,7 +51,7 @@ export class CollectionCurateComponent { filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { return this.dsoNameService.getName(rd.payload); - }) + }), ); } } diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html index ffd8f713436..845c82458a5 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html @@ -1,22 +1,23 @@
- + {{ 'collection.edit.template.label' | translate}}
diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts index 7cc54bd994c..8a02f0c1d44 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts @@ -1,20 +1,37 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../shared/shared.module'; import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + NavigationEnd, + Router, +} from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CollectionMetadataComponent } from './collection-metadata.component'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { Item } from '../../../core/shared/item.model'; + +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; +import { AuthService } from '../../../core/auth/auth.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { CommunityDataService } from '../../../core/data/community-data.service'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; -import { Collection } from '../../../core/shared/collection.model'; import { RequestService } from '../../../core/data/request.service'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Collection } from '../../../core/shared/collection.model'; +import { Item } from '../../../core/shared/item.model'; +import { AuthServiceMock } from '../../../shared/mocks/auth.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; +import { CollectionMetadataComponent } from './collection-metadata.component'; describe('CollectionMetadataComponent', () => { let comp: CollectionMetadataComponent; @@ -24,16 +41,16 @@ describe('CollectionMetadataComponent', () => { const template = Object.assign(new Item(), { _links: { - self: { href: 'template-selflink' } - } + self: { href: 'template-selflink' }, + }, }); const collection = Object.assign(new Collection(), { uuid: 'collection-id', id: 'collection-id', name: 'Fake Collection', _links: { - self: { href: 'collection-selflink' } - } + self: { href: 'collection-selflink' }, + }, }); const collectionTemplateHref = 'rest/api/test/collections/template'; @@ -46,10 +63,10 @@ describe('CollectionMetadataComponent', () => { const notificationsService = jasmine.createSpyObj('notificationsService', { success: {}, - error: {} + error: {}, }); const requestService = jasmine.createSpyObj('requestService', { - setStaleByHrefSubstring: {} + setStaleByHrefSubstring: {}, }); const routerMock = { @@ -59,17 +76,20 @@ describe('CollectionMetadataComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [CollectionMetadataComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, CollectionMetadataComponent], providers: [ { provide: CollectionDataService, useValue: {} }, { provide: ItemTemplateDataService, useValue: itemTemplateServiceStub }, { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, { provide: NotificationsService, useValue: notificationsService }, { provide: RequestService, useValue: requestService }, - { provide: Router, useValue: routerMock} + { provide: Router, useValue: routerMock }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: CommunityDataService, useValue: {} }, + { provide: ObjectCacheService, useValue: {} }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts index 634363527f7..98127d891c7 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -1,20 +1,49 @@ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; -import { Collection } from '../../../core/shared/collection.model'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + NavigationEnd, + Router, + RouterLink, + Scroll, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + combineLatest as combineLatestObservable, + Observable, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { ActivatedRoute, NavigationEnd, Router, Scroll } from '@angular/router'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; -import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; -import { Item } from '../../../core/shared/item.model'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { map, switchMap } from 'rxjs/operators'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../../core/data/request.service'; -import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; +import { Collection } from '../../../core/shared/collection.model'; +import { Item } from '../../../core/shared/item.model'; import { NoContent } from '../../../core/shared/NoContent.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../../core/shared/operators'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { CollectionFormComponent } from '../../collection-form/collection-form.component'; +import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; /** * Component for editing a collection's metadata @@ -22,6 +51,15 @@ import { hasValue } from '../../../shared/empty.util'; @Component({ selector: 'ds-collection-metadata', templateUrl: './collection-metadata.component.html', + imports: [ + CollectionFormComponent, + RouterLink, + AsyncPipe, + TranslateModule, + NgIf, + VarDirective, + ], + standalone: true, }) export class CollectionMetadataComponent extends ComcolMetadataComponent implements OnInit { protected frontendURL = '/collections/'; @@ -40,7 +78,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.findByCollectionID(collection.uuid)) + switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid)), ); } diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts index c375a23ddf9..76ab4079f2c 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts @@ -1,22 +1,30 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { CollectionRolesComponent } from './collection-roles.component'; -import { Collection } from '../../../core/shared/collection.model'; -import { SharedModule } from '../../../shared/shared.module'; -import { GroupDataService } from '../../../core/eperson/group-data.service'; + +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { RequestService } from '../../../core/data/request.service'; -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 { GroupDataService } from '../../../core/eperson/group-data.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { CollectionRolesComponent } from './collection-roles.component'; describe('CollectionRolesComponent', () => { @@ -54,10 +62,10 @@ describe('CollectionRolesComponent', () => { }, ], }, - }) + }), ), - }) - } + }), + }, }; const requestService = { @@ -70,13 +78,9 @@ describe('CollectionRolesComponent', () => { TestBed.configureTestingModule({ imports: [ - ComcolModule, - SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), - NoopAnimationsModule - ], - declarations: [ + NoopAnimationsModule, CollectionRolesComponent, ], providers: [ @@ -84,9 +88,9 @@ describe('CollectionRolesComponent', () => { { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, - { provide: NotificationsService, useClass: NotificationsServiceStub } + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(CollectionRolesComponent); diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts index 0177cc3a386..f402c99caf2 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts @@ -1,11 +1,26 @@ -import { Component, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgForOf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { Collection } from '../../../core/shared/collection.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { HALLink } from '../../../core/shared/hal-link.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; +import { ComcolRoleComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component'; import { hasValue } from '../../../shared/empty.util'; /** @@ -14,6 +29,12 @@ import { hasValue } from '../../../shared/empty.util'; @Component({ selector: 'ds-collection-roles', templateUrl: './collection-roles.component.html', + imports: [ + ComcolRoleComponent, + NgForOf, + AsyncPipe, + ], + standalone: true, }) export class CollectionRolesComponent implements OnInit { diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html index 4d7b3e657ec..1e09758bd10 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html @@ -1,6 +1,6 @@
-

{{ 'collection.source.controls.head' | translate }}

+

{{ 'collection.source.controls.head' | translate }}

{{'collection.source.controls.harvest.status' | translate}} {{contentSource?.harvestStatus}} @@ -18,33 +18,33 @@

{{ 'collection.source.controls.head' | translate }}

{{contentSource?.message ? contentSource?.message: 'collection.source.controls.harvest.no-information'|translate }}
- - - diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts index 3eb83ebe8ac..cbe3de14834 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts @@ -1,27 +1,33 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ContentSource } from '../../../../core/shared/content-source.model'; -import { Collection } from '../../../../core/shared/collection.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { TranslateModule } from '@ngx-translate/core'; +import { HttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { CollectionDataService } from '../../../../core/data/collection-data.service'; -import { RequestService } from '../../../../core/data/request.service'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; -import { HttpClient } from '@angular/common/http'; -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { Process } from '../../../../process-page/processes/process.model'; -import { of as observableOf } from 'rxjs'; -import { CollectionSourceControlsComponent } from './collection-source-controls.component'; +import { RequestService } from '../../../../core/data/request.service'; import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { By } from '@angular/platform-browser'; -import { VarDirective } from '../../../../shared/utils/var.directive'; +import { Collection } from '../../../../core/shared/collection.model'; +import { ContentSource } from '../../../../core/shared/content-source.model'; import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; +import { Process } from '../../../../process-page/processes/process.model'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { CollectionSourceControlsComponent } from './collection-source-controls.component'; describe('CollectionSourceControlsComponent', () => { let comp: CollectionSourceControlsComponent; @@ -51,44 +57,44 @@ describe('CollectionSourceControlsComponent', () => { { id: 'dc', label: 'Simple Dublin Core', - nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/' + nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/', }, { id: 'qdc', label: 'Qualified Dublin Core', - nameSpace: 'http://purl.org/dc/terms/' + nameSpace: 'http://purl.org/dc/terms/', }, { id: 'dim', label: 'DSpace Intermediate Metadata', - nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' - } + nameSpace: 'http://www.dspace.org/xmlns/dspace/dim', + }, ], oaiSource: 'oai-harvest-source', oaiSetId: 'oai-set-id', - _links: {self: {href: 'contentsource-selflink'}} + _links: { self: { href: 'contentsource-selflink' } }, }); process = Object.assign(new Process(), { processId: 'process-id', processStatus: 'COMPLETED', - _links: {output: {href: 'output-href'}} + _links: { output: { href: 'output-href' } }, }); - bitstream = Object.assign(new Bitstream(), {_links: {content: {href: 'content-href'}}}); + bitstream = Object.assign(new Bitstream(), { _links: { content: { href: 'content-href' } } }); collection = Object.assign(new Collection(), { uuid: 'fake-collection-id', - _links: {self: {href: 'collection-selflink'}} + _links: { self: { href: 'collection-selflink' } }, }); notificationsService = new NotificationsServiceStub(); collectionService = jasmine.createSpyObj('collectionService', { getContentSource: createSuccessfulRemoteDataObject$(contentSource), - findByHref: createSuccessfulRemoteDataObject$(collection) + findByHref: createSuccessfulRemoteDataObject$(collection), }); scriptDataService = jasmine.createSpyObj('scriptDataService', { invoke: createSuccessfulRemoteDataObject$(process), }); processDataService = jasmine.createSpyObj('processDataService', { - findById: createSuccessfulRemoteDataObject$(process), + autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process), }); bitstreamService = jasmine.createSpyObj('bitstreamService', { findByHref: createSuccessfulRemoteDataObject$(bitstream), @@ -99,18 +105,17 @@ describe('CollectionSourceControlsComponent', () => { requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule], - declarations: [CollectionSourceControlsComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule, CollectionSourceControlsComponent, VarDirective, BtnDisabledDirective], providers: [ - {provide: ScriptDataService, useValue: scriptDataService}, - {provide: ProcessDataService, useValue: processDataService}, - {provide: RequestService, useValue: requestService}, - {provide: NotificationsService, useValue: notificationsService}, - {provide: CollectionDataService, useValue: collectionService}, - {provide: HttpClient, useValue: httpClient}, - {provide: BitstreamDataService, useValue: bitstreamService} + { provide: ScriptDataService, useValue: scriptDataService }, + { provide: ProcessDataService, useValue: processDataService }, + { provide: RequestService, useValue: requestService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: CollectionDataService, useValue: collectionService }, + { provide: HttpClient, useValue: httpClient }, + { provide: BitstreamDataService, useValue: bitstreamService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(() => { @@ -132,12 +137,12 @@ describe('CollectionSourceControlsComponent', () => { scheduler.flush(); expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [ - {name: '-g', value: null}, - {name: '-a', value: contentSource.oaiSource}, - {name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)}, + { name: '-g', value: null }, + { name: '-a', value: contentSource.oaiSource }, + { name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId) }, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href); expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text'); }); @@ -148,10 +153,10 @@ describe('CollectionSourceControlsComponent', () => { scheduler.flush(); expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [ - {name: '-r', value: null}, - {name: '-c', value: collection.uuid}, + { name: '-r', value: null }, + { name: '-c', value: collection.uuid }, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); }); }); @@ -161,10 +166,10 @@ describe('CollectionSourceControlsComponent', () => { scheduler.flush(); expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [ - {name: '-o', value: null}, - {name: '-c', value: collection.uuid}, + { name: '-o', value: null }, + { name: '-c', value: collection.uuid }, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); }); }); @@ -189,9 +194,10 @@ describe('CollectionSourceControlsComponent', () => { const buttons = fixture.debugElement.queryAll(By.css('button')); - expect(buttons[0].nativeElement.disabled).toBeTrue(); - expect(buttons[1].nativeElement.disabled).toBeTrue(); - expect(buttons[2].nativeElement.disabled).toBeTrue(); + buttons.forEach(button => { + expect(button.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(button.nativeElement.classList.contains('disabled')).toBeTrue(); + }); }); it('should be enabled when isEnabled is true', () => { comp.shouldShow = true; @@ -201,9 +207,10 @@ describe('CollectionSourceControlsComponent', () => { const buttons = fixture.debugElement.queryAll(By.css('button')); - expect(buttons[0].nativeElement.disabled).toBeFalse(); - expect(buttons[1].nativeElement.disabled).toBeFalse(); - expect(buttons[2].nativeElement.disabled).toBeFalse(); + buttons.forEach(button => { + expect(button.nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(button.nativeElement.classList.contains('disabled')).toBeFalse(); + }); }); it('should call the corresponding button when clicked', () => { spyOn(comp, 'testConfiguration'); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts index 7113c25e9f6..e35a64af16d 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts @@ -1,26 +1,49 @@ -import { Component, Input, OnDestroy } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + Subscription, +} from 'rxjs'; +import { + filter, + map, + switchMap, + tap, +} from 'rxjs/operators'; + +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { Collection } from '../../../../core/shared/collection.model'; import { ContentSource } from '../../../../core/shared/content-source.model'; -import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; +import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; import { - getAllCompletedRemoteData, getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload + getFirstSucceededRemoteDataPayload, } from '../../../../core/shared/operators'; -import { filter, map, switchMap, tap } from 'rxjs/operators'; -import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; +import { Process } from '../../../../process-page/processes/process.model'; import { ProcessStatus } from '../../../../process-page/processes/process-status.model'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -import { RequestService } from '../../../../core/data/request.service'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; +import { hasValue } from '../../../../shared/empty.util'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { Collection } from '../../../../core/shared/collection.model'; -import { CollectionDataService } from '../../../../core/data/collection-data.service'; -import { Process } from '../../../../process-page/processes/process.model'; -import { TranslateService } from '@ngx-translate/core'; -import { HttpClient } from '@angular/common/http'; -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; +import { VarDirective } from '../../../../shared/utils/var.directive'; /** * Component that contains the controls to run, reset and test the harvest @@ -29,8 +52,16 @@ import { ContentSourceSetSerializer } from '../../../../core/shared/content-sour selector: 'ds-collection-source-controls', styleUrls: ['./collection-source-controls.component.scss'], templateUrl: './collection-source-controls.component.html', + imports: [ + TranslateModule, + AsyncPipe, + NgIf, + VarDirective, + BtnDisabledDirective, + ], + standalone: true, }) -export class CollectionSourceControlsComponent implements OnDestroy { +export class CollectionSourceControlsComponent implements OnInit, OnDestroy { /** * Should the controls be enabled. @@ -49,6 +80,7 @@ export class CollectionSourceControlsComponent implements OnDestroy { contentSource$: Observable; private subs: Subscription[] = []; + private autoRefreshIDs: string[] = []; testConfigRunning$ = new BehaviorSubject(false); importRunning$ = new BehaviorSubject(false); @@ -61,16 +93,16 @@ export class CollectionSourceControlsComponent implements OnDestroy { private collectionService: CollectionDataService, private translateService: TranslateService, private httpClient: HttpClient, - private bitstreamService: BitstreamDataService + private bitstreamService: BitstreamDataService, ) { } - ngOnInit() { + ngOnInit(): void { // ensure the contentSource gets updated after being set to stale this.contentSource$ = this.collectionService.findByHref(this.collection._links.self.href, false).pipe( getAllSucceededRemoteDataPayload(), switchMap((collection) => this.collectionService.getContentSource(collection.uuid, false)), - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -81,9 +113,9 @@ export class CollectionSourceControlsComponent implements OnDestroy { testConfiguration(contentSource) { this.testConfigRunning$.next(true); this.subs.push(this.scriptDataService.invoke('harvest', [ - {name: '-g', value: null}, - {name: '-a', value: contentSource.oaiSource}, - {name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)}, + { name: '-g', value: null }, + { name: '-a', value: contentSource.oaiSource }, + { name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId) }, ], []).pipe( getFirstCompletedRemoteData(), tap((rd) => { @@ -95,36 +127,28 @@ export class CollectionSourceControlsComponent implements OnDestroy { }), // filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful. filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), map((rd) => rd.payload), - hasValueOperator(), ).subscribe((process: Process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); - this.testConfigRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { - this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => { - const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') - .replaceAll('The script has started', '') - .replaceAll('The script has completed', ''); - this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); - }); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); + this.testConfigRunning$.next(false); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { + this.httpClient.get(bitstream._links.content.href, { responseType: 'text' }).subscribe((data: any) => { + const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') + .replaceAll('The script has started', '') + .replaceAll('The script has completed', ''); + this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); }); - this.testConfigRunning$.next(false); - } + }); + this.testConfigRunning$.next(false); } - )); + })); } /** @@ -133,8 +157,8 @@ export class CollectionSourceControlsComponent implements OnDestroy { importNow() { this.importRunning$.next(true); this.subs.push(this.scriptDataService.invoke('harvest', [ - {name: '-r', value: null}, - {name: '-c', value: this.collection.uuid}, + { name: '-r', value: null }, + { name: '-c', value: this.collection.uuid }, ], []) .pipe( getFirstCompletedRemoteData(), @@ -147,31 +171,22 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), map((rd) => rd.payload), - hasValueOperator(), ).subscribe((process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); - this.importRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - this.importRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); + this.importRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.importRunning$.next(false); + } + })); } /** @@ -180,8 +195,8 @@ export class CollectionSourceControlsComponent implements OnDestroy { resetAndReimport() { this.reImportRunning$.next(true); this.subs.push(this.scriptDataService.invoke('harvest', [ - {name: '-o', value: null}, - {name: '-c', value: this.collection.uuid}, + { name: '-o', value: null }, + { name: '-c', value: this.collection.uuid }, ], []) .pipe( getFirstCompletedRemoteData(), @@ -194,31 +209,22 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), map((rd) => rd.payload), - hasValueOperator(), ).subscribe((process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); - this.reImportRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - this.reImportRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); + this.reImportRunning$.next(false); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.reImportRunning$.next(false); } - )); + })); } ngOnDestroy(): void { @@ -227,5 +233,9 @@ export class CollectionSourceControlsComponent implements OnDestroy { sub.unsubscribe(); } }); + + this.autoRefreshIDs.forEach((id) => { + this.processDataService.stopAutoRefreshing(id); + }); } } 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 d7b0d0c475e..7aa1f1a8b78 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,32 +1,32 @@
- -
-

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

+

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

- -

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

+ +

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

{{
- -
diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts index e7e98d95233..3457d751756 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -1,25 +1,51 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + DynamicFormControlModel, + DynamicFormService, +} from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CollectionSourceComponent } from './collection-source.component'; -import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; + +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { RequestService } from '../../../core/data/request.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { + ContentSource, + ContentSourceHarvestType, +} from '../../../core/shared/content-source.model'; +import { hasValue } from '../../../shared/empty.util'; +import { FormComponent } from '../../../shared/form/form.component'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; +import { + INotification, + Notification, +} from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core'; -import { hasValue } from '../../../shared/empty.util'; -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; import { RouterStub } from '../../../shared/testing/router.stub'; -import { By } from '@angular/platform-browser'; -import { Collection } from '../../../core/shared/collection.model'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { RequestService } from '../../../core/data/request.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { CollectionSourceComponent } from './collection-source.component'; +import { CollectionSourceControlsComponent } from './collection-source-controls/collection-source-controls.component'; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -50,29 +76,29 @@ describe('CollectionSourceComponent', () => { { id: 'dc', label: 'Simple Dublin Core', - nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/' + nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/', }, { id: 'qdc', label: 'Qualified Dublin Core', - nameSpace: 'http://purl.org/dc/terms/' + nameSpace: 'http://purl.org/dc/terms/', }, { id: 'dim', label: 'DSpace Intermediate Metadata', - nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' - } + nameSpace: 'http://www.dspace.org/xmlns/dspace/dim', + }, ], - _links: { self: { href: 'contentsource-selflink' } } + _links: { self: { href: 'contentsource-selflink' } }, }); fieldUpdate = { field: contentSource, - changeType: undefined + changeType: undefined, }; objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { getFieldUpdates: observableOf({ - [contentSource.uuid]: fieldUpdate + [contentSource.uuid]: fieldUpdate, }), saveAddFieldUpdate: {}, discardFieldUpdates: {}, @@ -82,15 +108,15 @@ describe('CollectionSourceComponent', () => { getLastModified: observableOf(date), hasUpdates: observableOf(true), isReinstatable: observableOf(false), - isValidPage: observableOf(true) - } + isValidPage: observableOf(true), + }, ); notificationsService = jasmine.createSpyObj('notificationsService', { info: infoNotification, warning: warningNotification, - success: successNotification - } + success: successNotification, + }, ); location = jasmine.createSpyObj('location', ['back']); formService = Object.assign({ @@ -103,24 +129,23 @@ describe('CollectionSourceComponent', () => { return new UntypedFormGroup(controls); } return undefined; - } + }, }); router = Object.assign(new RouterStub(), { - url: 'http://test-url.com/test-url' + url: 'http://test-url.com/test-url', }); collection = Object.assign(new Collection(), { - uuid: 'fake-collection-id' + uuid: 'fake-collection-id', }); collectionService = jasmine.createSpyObj('collectionService', { getContentSource: createSuccessfulRemoteDataObject$(contentSource), updateContentSource: observableOf(contentSource), - getHarvesterEndpoint: observableOf('harvester-endpoint') + getHarvesterEndpoint: observableOf('harvester-endpoint'), }); requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule], - declarations: [CollectionSourceComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule, CollectionSourceComponent], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, { provide: NotificationsService, useValue: notificationsService }, @@ -129,10 +154,18 @@ describe('CollectionSourceComponent', () => { { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, { provide: Router, useValue: router }, { provide: CollectionDataService, useValue: collectionService }, - { provide: RequestService, useValue: requestService } + { provide: RequestService, useValue: requestService }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(CollectionSourceComponent, { + remove: { imports: [ + ThemedLoadingComponent, + FormComponent, + CollectionSourceControlsComponent, + ] }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts index 2d1308cc83a..afeb2e2352c 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -1,5 +1,18 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; +import { + AsyncPipe, + Location, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { DynamicFormControlModel, DynamicFormGroupModel, @@ -8,29 +21,53 @@ import { DynamicInputModel, DynamicOptionControlModel, DynamicRadioGroupModel, - DynamicSelectModel + DynamicSelectModel, } from '@ng-dynamic-forms/core'; -import { Location } from '@angular/common'; -import { TranslateService } from '@ngx-translate/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import cloneDeep from 'lodash/cloneDeep'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + first, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../../environments/environment'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { UntypedFormGroup } from '@angular/forms'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; -import { Observable, Subscription } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; import { Collection } from '../../../core/shared/collection.model'; -import { first, map, switchMap, take } from 'rxjs/operators'; -import { ActivatedRoute, Router } from '@angular/router'; -import cloneDeep from 'lodash/cloneDeep'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { + ContentSource, + ContentSourceHarvestType, +} from '../../../core/shared/content-source.model'; import { MetadataConfig } from '../../../core/shared/metadata-config.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, +} from '../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { FormComponent } from '../../../shared/form/form.component'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { INotification } from '../../../shared/notifications/models/notification.model'; -import { RequestService } from '../../../core/data/request.service'; -import { environment } from '../../../../environments/environment'; -import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; -import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; +import { CollectionSourceControlsComponent } from './collection-source-controls/collection-source-controls.component'; /** * Component for managing the content source of the collection @@ -38,6 +75,16 @@ import { FieldUpdates } from '../../../core/data/object-updates/field-updates.mo @Component({ selector: 'ds-collection-source', templateUrl: './collection-source.component.html', + imports: [ + AsyncPipe, + TranslateModule, + NgIf, + ThemedLoadingComponent, + FormComponent, + CollectionSourceControlsComponent, + BtnDisabledDirective, + ], + standalone: true, }) export class CollectionSourceComponent extends AbstractTrackableComponent implements OnInit, OnDestroy { /** @@ -84,11 +131,11 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem name: 'oaiSource', required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'You must provide a set id of the target collection.' - } + required: 'You must provide a set id of the target collection.', + }, }); /** @@ -96,7 +143,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ oaiSetIdModel = new DynamicInputModel({ id: 'oaiSetId', - name: 'oaiSetId' + name: 'oaiSetId', }); /** @@ -104,7 +151,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ metadataConfigIdModel = new DynamicSelectModel({ id: 'metadataConfigId', - name: 'metadataConfigId' + name: 'metadataConfigId', }); /** @@ -115,15 +162,15 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem name: 'harvestType', options: [ { - value: ContentSourceHarvestType.Metadata + value: ContentSourceHarvestType.Metadata, }, { - value: ContentSourceHarvestType.MetadataAndRef + value: ContentSourceHarvestType.MetadataAndRef, }, { - value: ContentSourceHarvestType.MetadataAndBitstreams - } - ] + value: ContentSourceHarvestType.MetadataAndBitstreams, + }, + ], }); /** @@ -139,22 +186,22 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem new DynamicFormGroupModel({ id: 'oaiSourceContainer', group: [ - this.oaiSourceModel - ] + this.oaiSourceModel, + ], }), new DynamicFormGroupModel({ id: 'oaiSetContainer', group: [ this.oaiSetIdModel, - this.metadataConfigIdModel - ] + this.metadataConfigIdModel, + ], }), new DynamicFormGroupModel({ id: 'harvestTypeContainer', group: [ - this.harvestTypeModel - ] - }) + this.harvestTypeModel, + ], + }), ]; /** @@ -163,40 +210,40 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem formLayout: DynamicFormLayout = { oaiSource: { grid: { - host: 'col-12 d-inline-block' - } + host: 'col-12 d-inline-block', + }, }, oaiSetId: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, metadataConfigId: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, harvestType: { grid: { host: 'col-12', - option: 'btn-outline-secondary' - } + option: 'btn-outline-secondary', + }, }, oaiSetContainer: { grid: { - host: 'row' - } + host: 'row', + }, }, oaiSourceContainer: { grid: { - host: 'row' - } + host: 'row', + }, }, harvestTypeContainer: { grid: { - host: 'row' - } - } + host: 'row', + }, + }, }; /** @@ -204,11 +251,6 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ formGroup: UntypedFormGroup; - /** - * Subscription to update the current form - */ - updateSub: Subscription; - /** * The content harvesting type used when harvesting is disabled */ @@ -228,28 +270,29 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ displayedNotifications: INotification[] = []; - public constructor(public objectUpdatesService: ObjectUpdatesService, - public notificationsService: NotificationsService, - protected location: Location, - protected formService: DynamicFormService, - protected translate: TranslateService, - protected route: ActivatedRoute, - protected router: Router, - protected collectionService: CollectionDataService, - protected requestService: RequestService) { - super(objectUpdatesService, notificationsService, translate); + subs: Subscription[] = []; + + public constructor( + public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + public translateService: TranslateService, + public router: Router, + protected location: Location, + protected formService: DynamicFormService, + protected route: ActivatedRoute, + protected collectionService: CollectionDataService, + protected requestService: RequestService, + ) { + super(objectUpdatesService, notificationsService, translateService, router); } /** * Initialize properties to setup the Field Update and Form */ ngOnInit(): void { + super.ngOnInit(); this.notificationsPrefix = 'collection.edit.tabs.source.notifications.'; this.discardTimeOut = environment.collection.edit.undoTimeout; - this.url = this.router.url; - if (this.url.indexOf('?') > 0) { - this.url = this.url.substr(0, this.url.indexOf('?')); - } this.formGroup = this.formService.createFormGroup(this.formModel); this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); @@ -263,10 +306,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem }); this.updateFieldTranslations(); - this.translate.onLangChange - .subscribe(() => { - this.updateFieldTranslations(); - }); + this.subs.push(this.translateService.onLangChange.subscribe(() => { + this.updateFieldTranslations(); + })); } /** @@ -279,9 +321,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem const initialContentSource = cloneDeep(this.contentSource); this.objectUpdatesService.initialize(this.url, [initialContentSource], new Date()); this.update$ = this.objectUpdatesService.getFieldUpdates(this.url, [initialContentSource]).pipe( - map((updates: FieldUpdates) => updates[initialContentSource.uuid]) + map((updates: FieldUpdates) => updates[initialContentSource.uuid]), ); - this.updateSub = this.update$.subscribe((update: FieldUpdate) => { + this.subs.push(this.update$.subscribe((update: FieldUpdate) => { if (update) { const field = update.field as ContentSource; let configId; @@ -294,21 +336,21 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (hasValue(field)) { this.formGroup.patchValue({ oaiSourceContainer: { - oaiSource: field.oaiSource + oaiSource: field.oaiSource, }, oaiSetContainer: { oaiSetId: field.oaiSetId, - metadataConfigId: configId + metadataConfigId: configId, }, harvestTypeContainer: { - harvestType: field.harvestType - } + harvestType: field.harvestType, + }, }); this.contentSource = cloneDeep(field); } this.contentSource.metadataConfigId = configId; } - }); + })); } /** @@ -320,8 +362,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (this.metadataConfigIdModel.options.length > 0) { this.formGroup.patchValue({ oaiSetContainer: { - metadataConfigId: this.metadataConfigIdModel.options[0].value - } + metadataConfigId: this.metadataConfigIdModel.options[0].value, + }, }); } } @@ -333,7 +375,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.inputModels.forEach( (fieldModel: DynamicFormControlModel) => { this.updateFieldTranslation(fieldModel); - } + }, ); } @@ -342,18 +384,18 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * @param fieldModel */ private updateFieldTranslation(fieldModel: DynamicFormControlModel) { - fieldModel.label = this.translate.instant(this.LABEL_KEY_PREFIX + fieldModel.id); + fieldModel.label = this.translateService.instant(this.LABEL_KEY_PREFIX + fieldModel.id); if (isNotEmpty(fieldModel.validators)) { fieldModel.errorMessages = {}; Object.keys(fieldModel.validators).forEach((key) => { - fieldModel.errorMessages[key] = this.translate.instant(this.ERROR_KEY_PREFIX + fieldModel.id + '.' + key); + fieldModel.errorMessages[key] = this.translateService.instant(this.ERROR_KEY_PREFIX + fieldModel.id + '.' + key); }); } if (fieldModel instanceof DynamicOptionControlModel) { if (isNotEmpty(fieldModel.options)) { fieldModel.options.forEach((option) => { if (hasNoValue(option.label)) { - option.label = this.translate.instant(this.OPTIONS_KEY_PREFIX + fieldModel.id + '.' + option.value); + option.label = this.translateService.instant(this.OPTIONS_KEY_PREFIX + fieldModel.id + '.' + option.value); } }); } @@ -378,7 +420,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem getFirstSucceededRemoteData(), map((col) => col.payload.uuid), switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)), - take(1) + take(1), ).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint)); this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href); // Update harvester @@ -386,7 +428,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem getFirstSucceededRemoteData(), map((col) => col.payload.uuid), switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), - take(1) + take(1), ).subscribe((result: ContentSource | INotification) => { if (hasValue((result as any).harvestType)) { this.clearNotifications(); @@ -433,7 +475,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.inputModels.forEach( (fieldModel: DynamicInputModel) => { this.updateContentSourceField(fieldModel, updateHarvestType); - } + }, ); this.saveFieldUpdate(); } @@ -470,8 +512,6 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Make sure open subscriptions are closed */ ngOnDestroy(): void { - if (this.updateSub) { - this.updateSub.unsubscribe(); - } + this.subs.forEach((sub: Subscription) => sub.unsubscribe()); } } diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page-routes.ts b/src/app/collection-page/edit-collection-page/edit-collection-page-routes.ts new file mode 100644 index 00000000000..19dbaa616b2 --- /dev/null +++ b/src/app/collection-page/edit-collection-page/edit-collection-page-routes.ts @@ -0,0 +1,100 @@ +import { Route } from '@angular/router'; + +import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { collectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { resourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { resourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { CollectionItemMapperComponent } from '../collection-item-mapper/collection-item-mapper.component'; +import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; +import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; +import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; +import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; +import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; +import { CollectionSourceComponent } from './collection-source/collection-source.component'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; + +/** + * Routing module that handles the routing for the Edit Collection page administrator functionality + */ + +export const ROUTES: Route[] = [ + { + path: '', + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'collection.edit' }, + component: EditCollectionPageComponent, + canActivate: [collectionAdministratorGuard], + children: [ + { + path: '', + redirectTo: 'metadata', + pathMatch: 'full', + }, + { + path: 'metadata', + component: CollectionMetadataComponent, + data: { + title: 'collection.edit.tabs.metadata.title', + hideReturnButton: true, + showBreadcrumbs: true, + }, + }, + { + path: 'roles', + component: CollectionRolesComponent, + data: { title: 'collection.edit.tabs.roles.title', showBreadcrumbs: true }, + }, + { + path: 'source', + component: CollectionSourceComponent, + data: { title: 'collection.edit.tabs.source.title', showBreadcrumbs: true }, + }, + { + path: 'curate', + component: CollectionCurateComponent, + data: { title: 'collection.edit.tabs.curate.title', showBreadcrumbs: true }, + }, + { + path: 'access-control', + component: CollectionAccessControlComponent, + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true }, + }, + { + path: 'authorizations', + data: { showBreadcrumbs: true }, + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: resourcePolicyTargetResolver, + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title' }, + }, + { + path: 'edit', + resolve: { + resourcePolicy: resourcePolicyResolver, + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title' }, + }, + { + path: '', + component: CollectionAuthorizationsComponent, + data: { title: 'collection.edit.tabs.authorizations.title', showBreadcrumbs: true }, + }, + ], + }, + { + path: 'mapper', + component: CollectionItemMapperComponent, + data: { title: 'collection.edit.tabs.item-mapper.title', hideReturnButton: true, showBreadcrumbs: true }, + }, + ], + }, +]; diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts index 00f05f50c0f..3ef2b65df92 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts @@ -1,50 +1,53 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { EditCollectionPageComponent } from './edit-collection-page.component'; -import { SharedModule } from '../../shared/shared.module'; -import { CollectionDataService } from '../../core/data/collection-data.service'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; + describe('EditCollectionPageComponent', () => { let comp: EditCollectionPageComponent; let fixture: ComponentFixture; const routeStub = { data: observableOf({ - dso: { payload: {} } + dso: { payload: {} }, }), routeConfig: { children: [ { path: 'mockUrl', data: { - hideReturnButton: false - } - } - ] + hideReturnButton: false, + }, + }, + ], }, snapshot: { firstChild: { routeConfig: { - path: 'mockUrl' - } - } - } + path: 'mockUrl', + }, + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [EditCollectionPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, EditCollectionPageComponent], providers: [ { provide: CollectionDataService, useValue: {} }, { provide: ActivatedRoute, useValue: routeStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts index 62fbb3ee3df..4508ba73427 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts @@ -1,7 +1,20 @@ +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { + ActivatedRoute, + Router, + RouterLink, + RouterOutlet, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Collection } from '../../core/shared/collection.model'; +import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { getCollectionPageRoute } from '../collection-page-routing-paths'; /** @@ -9,14 +22,24 @@ import { getCollectionPageRoute } from '../collection-page-routing-paths'; */ @Component({ selector: 'ds-edit-collection', - templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html', + imports: [ + RouterLink, + TranslateModule, + NgClass, + NgForOf, + RouterOutlet, + NgIf, + AsyncPipe, + ], + standalone: true, }) export class EditCollectionPageComponent extends EditComColPageComponent { type = 'collection'; public constructor( protected router: Router, - protected route: ActivatedRoute + protected route: ActivatedRoute, ) { super(router, route); } diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts deleted file mode 100644 index 8d0cb179f1f..00000000000 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NgModule } from '@angular/core'; -import { EditCollectionPageComponent } from './edit-collection-page.component'; -import { CommonModule } from '@angular/common'; -import { SharedModule } from '../../shared/shared.module'; -import { EditCollectionPageRoutingModule } from './edit-collection-page.routing.module'; -import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; -import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; -import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; -import { CollectionSourceComponent } from './collection-source/collection-source.component'; -import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; -import { CollectionFormModule } from '../collection-form/collection-form.module'; -import { - CollectionSourceControlsComponent -} from './collection-source/collection-source-controls/collection-source-controls.component'; -import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; -import { FormModule } from '../../shared/form/form.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; -import { AccessControlFormModule } from '../../shared/access-control-form-container/access-control-form.module'; - -/** - * Module that contains all components related to the Edit Collection page administrator functionality - */ -@NgModule({ - imports: [ - CommonModule, - SharedModule, - EditCollectionPageRoutingModule, - CollectionFormModule, - ResourcePoliciesModule, - FormModule, - ComcolModule, - AccessControlFormModule, - ], - declarations: [ - EditCollectionPageComponent, - CollectionMetadataComponent, - CollectionRolesComponent, - CollectionCurateComponent, - CollectionSourceComponent, - CollectionAccessControlComponent, - CollectionSourceControlsComponent, - CollectionAuthorizationsComponent - ] -}) -export class EditCollectionPageModule { - -} diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts deleted file mode 100644 index c4481985c0a..00000000000 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { RouterModule } from '@angular/router'; -import { NgModule } from '@angular/core'; -import { CollectionItemMapperComponent } from '../collection-item-mapper/collection-item-mapper.component'; -import { EditCollectionPageComponent } from './edit-collection-page.component'; -import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; -import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; -import { CollectionSourceComponent } from './collection-source/collection-source.component'; -import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; -import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; -import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; -import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; -import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; -import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; -import { CollectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard'; -import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; - -/** - * Routing module that handles the routing for the Edit Collection page administrator functionality - */ -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: '', - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { breadcrumbKey: 'collection.edit' }, - component: EditCollectionPageComponent, - canActivate: [CollectionAdministratorGuard], - children: [ - { - path: '', - redirectTo: 'metadata', - pathMatch: 'full' - }, - { - path: 'metadata', - component: CollectionMetadataComponent, - data: { - title: 'collection.edit.tabs.metadata.title', - hideReturnButton: true, - showBreadcrumbs: true - } - }, - { - path: 'roles', - component: CollectionRolesComponent, - data: { title: 'collection.edit.tabs.roles.title', showBreadcrumbs: true } - }, - { - path: 'source', - component: CollectionSourceComponent, - data: { title: 'collection.edit.tabs.source.title', showBreadcrumbs: true } - }, - { - path: 'curate', - component: CollectionCurateComponent, - data: { title: 'collection.edit.tabs.curate.title', showBreadcrumbs: true } - }, - { - path: 'access-control', - component: CollectionAccessControlComponent, - data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } - }, -/* { - path: 'authorizations', - component: CollectionAuthorizationsComponent, - data: { title: 'collection.edit.tabs.authorizations.title', showBreadcrumbs: true } - },*/ - { - path: 'authorizations', - data: { showBreadcrumbs: true }, - children: [ - { - path: 'create', - resolve: { - resourcePolicyTarget: ResourcePolicyTargetResolver - }, - component: ResourcePolicyCreateComponent, - data: { title: 'resource-policies.create.page.title' } - }, - { - path: 'edit', - resolve: { - resourcePolicy: ResourcePolicyResolver - }, - component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title' } - }, - { - path: '', - component: CollectionAuthorizationsComponent, - data: { title: 'collection.edit.tabs.authorizations.title', showBreadcrumbs: true } - } - ] - }, - { - path: 'mapper', - component: CollectionItemMapperComponent, - data: { title: 'collection.edit.tabs.item-mapper.title', hideReturnButton: true, showBreadcrumbs: true } - }, - ] - } - ]) - ], - providers: [ - ResourcePolicyResolver, - ResourcePolicyTargetResolver - ] -}) -export class EditCollectionPageRoutingModule { - -} diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html index 20afd701ffc..7f0b2efba22 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html @@ -2,11 +2,11 @@
-

{{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}

- +

{{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}

+
- +
diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts index 72b776dd7d5..4d33e7d008a 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts @@ -1,16 +1,28 @@ -import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../shared/shared.module'; -import { RouterTestingModule } from '@angular/router/testing'; import { CommonModule } from '@angular/common'; -import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + +import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; import { Collection } from '../../core/shared/collection.model'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { ThemeService } from '../../shared/theme-support/theme.service'; import { getCollectionEditRoute } from '../collection-page-routing-paths'; +import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; describe('EditItemTemplatePageComponent', () => { let comp: EditItemTemplatePageComponent; @@ -22,19 +34,24 @@ describe('EditItemTemplatePageComponent', () => { collection = Object.assign(new Collection(), { uuid: 'collection-id', id: 'collection-id', - name: 'Fake Collection' + name: 'Fake Collection', }); itemTemplateService = jasmine.createSpyObj('itemTemplateService', { - findByCollectionID: createSuccessfulRemoteDataObject$({}) + findByCollectionID: createSuccessfulRemoteDataObject$({}), }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [EditItemTemplatePageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, EditItemTemplatePageComponent], providers: [ { provide: ItemTemplateDataService, useValue: itemTemplateService }, - { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } } + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(EditItemTemplatePageComponent, { + remove: { + imports: [ThemedDsoEditMetadataComponent], + }, }).compileComponents(); })); diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts index 238ec5e37a2..f7c5dc4b14a 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts @@ -1,19 +1,50 @@ -import { Component, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + RouterLink, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { + first, + map, + switchMap, +} from 'rxjs/operators'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; -import { ActivatedRoute } from '@angular/router'; -import { first, map, switchMap } from 'rxjs/operators'; -import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; -import { getCollectionEditRoute } from '../collection-page-routing-paths'; import { Item } from '../../core/shared/item.model'; import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; +import { AlertComponent } from '../../shared/alert/alert.component'; import { AlertType } from '../../shared/alert/alert-type'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { getCollectionEditRoute } from '../collection-page-routing-paths'; @Component({ - selector: 'ds-edit-item-template-page', + selector: 'ds-base-edit-item-template-page', templateUrl: './edit-item-template-page.component.html', + imports: [ + ThemedDsoEditMetadataComponent, + RouterLink, + AsyncPipe, + VarDirective, + NgIf, + TranslateModule, + ThemedLoadingComponent, + AlertComponent, + ], + standalone: true, }) /** * Component for editing the item template of a collection diff --git a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts index 95f0d888e47..c622c622679 100644 --- a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts +++ b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts @@ -1,33 +1,30 @@ +import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { ItemTemplatePageResolver } from './item-template-page.resolver'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; +import { itemTemplatePageResolver } from './item-template-page.resolver'; -describe('ItemTemplatePageResolver', () => { +describe('itemTemplatePageResolver', () => { describe('resolve', () => { - let resolver: ItemTemplatePageResolver; + let resolver: any; let itemTemplateService: any; - let dsoNameService: DSONameServiceMock; const uuid = '1234-65487-12354-1235'; beforeEach(() => { itemTemplateService = { - findByCollectionID: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findByCollectionID: (id: string) => createSuccessfulRemoteDataObject$({ id }), }; - dsoNameService = new DSONameServiceMock(); - resolver = new ItemTemplatePageResolver(dsoNameService as DSONameService, itemTemplateService); + resolver = itemTemplatePageResolver; }); it('should resolve an item template with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + (resolver({ params: { id: uuid } } as any, undefined, itemTemplateService) as Observable) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts index 586617c44c1..d35cd0a3b04 100644 --- a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts +++ b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts @@ -1,34 +1,23 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; -import { Observable } from 'rxjs'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; - -/** - * This class represents a resolver that requests a specific collection's item template before the route is activated - */ -@Injectable() -export class ItemTemplatePageResolver implements Resolve> { - constructor( - public dsoNameService: DSONameService, - private itemTemplateService: ItemTemplateDataService, - ) { - } +import { followLink } from '../../shared/utils/follow-link-config.model'; - /** - * Method for resolving a collection's item template based on the parameters in the current route - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found item template based on the parameters in the current route, - * or an error if something went wrong - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.itemTemplateService.findByCollectionID(route.params.id, true, false, followLink('templateItemOf')).pipe( - getFirstCompletedRemoteData(), - ); - } -} +export const itemTemplatePageResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + itemTemplateService: ItemTemplateDataService = inject(ItemTemplateDataService), +): Observable> => { + return itemTemplateService.findByCollectionID(route.params.id, true, false, followLink('templateItemOf')).pipe( + getFirstCompletedRemoteData(), + ); +}; 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 index b53f4e6c45d..421049990ae 100644 --- 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 @@ -1,11 +1,14 @@ 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', + selector: 'ds-edit-item-template-page', styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html', + standalone: true, + imports: [EditItemTemplatePageComponent], }) /** * Component for editing the item template of a collection diff --git a/src/app/collection-page/themed-collection-page.component.ts b/src/app/collection-page/themed-collection-page.component.ts index 2faf418423a..c84d7c5fb44 100644 --- a/src/app/collection-page/themed-collection-page.component.ts +++ b/src/app/collection-page/themed-collection-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CollectionPageComponent } from './collection-page.component'; @@ -6,9 +7,11 @@ import { CollectionPageComponent } from './collection-page.component'; * Themed wrapper for CollectionPageComponent */ @Component({ - selector: 'ds-themed-collection-page', + selector: 'ds-collection-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', + standalone: true, + imports: [CollectionPageComponent], }) export class ThemedCollectionPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index e2a2bb748f4..95acc9dd862 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,10 +1,18 @@ -import { hasValue } from '../shared/empty.util'; -import { CommunityListService} from './community-list-service'; -import { CollectionViewer, DataSource } from '@angular/cdk/collections'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +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'; +import { hasValue } from '../shared/empty.util'; +import { CommunityListService } from './community-list-service'; +import { FlatNode } from './flat-node.model'; /** * DataSource object needed by a CDK Tree to render its nodes. diff --git a/src/app/community-list-page/community-list-page-routes.ts b/src/app/community-list-page/community-list-page-routes.ts new file mode 100644 index 00000000000..9990efb4377 --- /dev/null +++ b/src/app/community-list-page/community-list-page-routes.ts @@ -0,0 +1,19 @@ +import { Route } from '@angular/router'; + +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ThemedCommunityListPageComponent } from './themed-community-list-page.component'; + +/** + * RouterModule to help navigate to the page with the community list tree + */ +export const ROUTES: Route[] = [ + { + path: '', + component: ThemedCommunityListPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'communityList.tabTitle', breadcrumbKey: 'communityList' }, + }, +]; 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 baed4b2e3d1..ca052014780 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 }}

- +

{{ 'communityList.title' | translate }}

+
diff --git a/src/app/community-list-page/community-list-page.component.spec.ts b/src/app/community-list-page/community-list-page.component.spec.ts index 080a0a9e18d..8afcf4466fc 100644 --- a/src/app/community-list-page/community-list-page.component.spec.ts +++ b/src/app/community-list-page/community-list-page.component.spec.ts @@ -1,9 +1,20 @@ -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; -import { CommunityListPageComponent } from './community-list-page.component'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { getMockThemeService } from '../shared/mocks/theme-service.mock'; import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ThemeService } from '../shared/theme-support/theme.service'; +import { CommunityListPageComponent } from './community-list-page.component'; +import { CommunityListService } from './community-list-service'; describe('CommunityListPageComponent', () => { let component: CommunityListPageComponent; @@ -15,13 +26,15 @@ describe('CommunityListPageComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock + useClass: TranslateLoaderMock, }, }), + CommunityListPageComponent, ], - declarations: [CommunityListPageComponent], providers: [ CommunityListPageComponent, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: CommunityListService, useValue: {} }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) diff --git a/src/app/community-list-page/community-list-page.component.ts b/src/app/community-list-page/community-list-page.component.ts index 5ab3cce5de3..ca0db89f53d 100644 --- a/src/app/community-list-page/community-list-page.component.ts +++ b/src/app/community-list-page/community-list-page.component.ts @@ -1,12 +1,17 @@ import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ThemedCommunityListComponent } from './community-list/themed-community-list.component'; /** * Page with title and the community list tree, as described in community-list.component; * navigated to with community-list.page.routing.module */ @Component({ - selector: 'ds-community-list-page', + selector: 'ds-base-community-list-page', templateUrl: './community-list-page.component.html', + standalone: true, + imports: [ThemedCommunityListComponent, TranslateModule], }) export class CommunityListPageComponent { diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts deleted file mode 100644 index 15946b2e89a..00000000000 --- a/src/app/community-list-page/community-list-page.module.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { SharedModule } from '../shared/shared.module'; -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'; -import { CdkTreeModule } from '@angular/cdk/tree'; - - -const DECLARATIONS = [ - CommunityListPageComponent, - CommunityListComponent, - ThemedCommunityListPageComponent, - ThemedCommunityListComponent -]; -/** - * The page which houses a title and the community list, as described in community-list.component - */ -@NgModule({ - imports: [ - CommonModule, - SharedModule, - CommunityListPageRoutingModule, - CdkTreeModule, - ], - declarations: [ - ...DECLARATIONS - ], - exports: [ - ...DECLARATIONS, - CdkTreeModule, - ], -}) -export class CommunityListPageModule { - -} diff --git a/src/app/community-list-page/community-list-page.routing.module.ts b/src/app/community-list-page/community-list-page.routing.module.ts deleted file mode 100644 index 1754b1b7cf3..00000000000 --- a/src/app/community-list-page/community-list-page.routing.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { CdkTreeModule } from '@angular/cdk/tree'; - -import { CommunityListService } from './community-list-service'; -import { ThemedCommunityListPageComponent } from './themed-community-list-page.component'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; - -/** - * RouterModule to help navigate to the page with the community list tree - */ -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: '', - component: ThemedCommunityListPageComponent, - pathMatch: 'full', - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'communityList.tabTitle', breadcrumbKey: 'communityList' } - } - ]), - CdkTreeModule, - ], - providers: [CommunityListService] -}) -export class CommunityListPageRoutingModule { -} 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 410dd9f8046..28d3cfe1a95 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -1,22 +1,35 @@ -import { inject, TestBed } from '@angular/core/testing'; +import { + inject, + TestBed, +} from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { take } from 'rxjs/operators'; +import { APP_CONFIG } from 'src/config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; + import { AppState } from '../app.reducer'; -import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -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, toFlatNode } from './community-list-service'; +import { + SortDirection, + SortOptions, +} from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityDataService } from '../core/data/community-data.service'; -import { Community } from '../core/shared/community.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { buildPaginatedList } from '../core/data/paginated-list.model'; import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; import { PageInfo } from '../core/shared/page-info.model'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../shared/remote-data.utils'; +import { StoreMock } from '../shared/testing/store.mock'; +import { + CommunityListService, + toFlatNode, +} from './community-list-service'; 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; @@ -38,34 +51,34 @@ describe('CommunityListService', () => { id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', }), - Object.assign(new Community(), { - id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - }) + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + }), ]; mockCollectionsPage1 = [ Object.assign(new Collection(), { id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', uuid: 'e9dbf393-7127-415f-8919-55be34a6e9ed', - name: 'Collection 1' + name: 'Collection 1', }), Object.assign(new Collection(), { id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', - name: 'Collection 2' - }) + name: 'Collection 2', + }), ]; mockCollectionsPage2 = [ Object.assign(new Collection(), { id: 'a5159760-f362-4659-9e81-e3253ad91ede', uuid: 'a5159760-f362-4659-9e81-e3253ad91ede', - name: 'Collection 3' + name: 'Collection 3', }), Object.assign(new Collection(), { id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', - name: 'Collection 4' - }) + name: 'Collection 4', + }), ]; mockListOfTopCommunitiesPage1 = [ Object.assign(new Community(), { @@ -164,7 +177,7 @@ describe('CommunityListService', () => { } else { return createFailedRemoteDataObject$(); } - } + }, }; collectionDataServiceStub = { findByParent(parentUUID: string, options: FindListOptions = {}) { @@ -189,7 +202,7 @@ describe('CommunityListService', () => { } else { return createFailedRemoteDataObject$(); } - } + }, }; TestBed.configureTestingModule({ providers: [CommunityListService, @@ -217,7 +230,7 @@ describe('CommunityListService', () => { service.loadCommunities({ currentPage: 2, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, null) .pipe(take(1)) .subscribe((value) => { @@ -246,7 +259,7 @@ describe('CommunityListService', () => { beforeEach((done) => { service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, null) .pipe(take(1)) .subscribe((value) => { @@ -279,7 +292,7 @@ describe('CommunityListService', () => { }); service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, expandedNodes) .pipe(take(1)) .subscribe((value) => { @@ -307,7 +320,7 @@ describe('CommunityListService', () => { const expandedNodes = [communityFlatNode]; service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, expandedNodes) .pipe(take(1)) .subscribe((value) => { @@ -332,7 +345,7 @@ describe('CommunityListService', () => { const expandedNodes = [communityFlatNode]; service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, expandedNodes) .pipe(take(1)) .subscribe((value) => { @@ -429,8 +442,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: 'no subcoms, 2 coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 2' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 2' }], + }, }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { @@ -461,8 +474,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { @@ -495,8 +508,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); let flatNodeList; beforeEach((done) => { @@ -540,8 +553,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); const communityFlatNode = toFlatNode(communityWithCollections, observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 2; @@ -591,8 +604,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); service.getIsExpandable(communityWithSubcoms).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); @@ -607,8 +620,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockCollectionsPage1)), metadata: { 'dc.description': [{ language: 'en_US', value: 'no subcoms, 2 coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 2' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 2' }], + }, }); service.getIsExpandable(communityWithCollections).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); @@ -625,8 +638,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: 'no subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 3' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 3' }], + }, }); service.getIsExpandable(communityWithNoSubcomsOrColls).pipe(take(1)).subscribe((result) => { expect(result).toEqual(false); diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index 67715716dad..2878d899ebc 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -1,29 +1,55 @@ /* 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'; -import { filter, map, switchMap } from 'rxjs/operators'; +import { + Inject, + Injectable, +} from '@angular/core'; +import { + createSelector, + Store, +} from '@ngrx/store'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + switchMap, +} from 'rxjs/operators'; +import { + APP_CONFIG, + AppConfig, +} from 'src/config/app-config.interface'; +import { v4 as uuidv4 } from 'uuid'; import { AppState } from '../app.reducer'; +import { getCollectionPageRoute } from '../collection-page/collection-page-routing-paths'; +import { getCommunityPageRoute } from '../community-page/community-page-routing-paths'; +import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityDataService } from '../core/data/community-data.service'; -import { Community } from '../core/shared/community.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, +} from '../core/shared/operators'; import { PageInfo } from '../core/shared/page-info.model'; -import { hasValue, isNotEmpty } from '../shared/empty.util'; -import { RemoteData } from '../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; -import { CollectionDataService } from '../core/data/collection-data.service'; +import { + hasValue, + isNotEmpty, +} from '../shared/empty.util'; +import { followLink } from '../shared/utils/follow-link-config.model'; 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 { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../core/shared/operators'; -import { followLink } from '../shared/utils/follow-link-config.model'; 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 and flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Observable[]): Observable => @@ -45,7 +71,7 @@ export const toFlatNode = ( isExpandable: Observable, level: number, isExpanded: boolean, - parent?: FlatNode + parent?: FlatNode, ): FlatNode => ({ isExpandable$: isExpandable, name: c.name, @@ -64,7 +90,7 @@ export const toFlatNode = ( export const showMoreFlatNode = ( id: string, level: number, - parent: FlatNode + parent: FlatNode, ): FlatNode => ({ isExpandable$: observableOf(false), name: 'Show More Flatnode', @@ -85,7 +111,7 @@ const loadingNodeSelector = createSelector(communityListStateSelector, (communit * 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 */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class CommunityListService { private pageSize: number; @@ -94,13 +120,13 @@ export class CommunityListService { @Inject(APP_CONFIG) protected appConfig: AppConfig, private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, - private store: Store + private store: Store, ) { this.pageSize = appConfig.communityList.pageSize; } private configOnePage: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 1 + elementsPerPage: 1, }); saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void { @@ -137,7 +163,7 @@ export class CommunityListService { newPageInfo = Object.assign({}, coms[0].pageInfo, { currentPage }); } return buildPaginatedList(newPageInfo, newPage); - }) + }), ); return topComs$.pipe( switchMap((topComs: PaginatedList) => this.transformListOfCommunities(topComs, 0, null, expandedNodes)), @@ -150,15 +176,15 @@ export class CommunityListService { */ private getTopCommunities(options: FindListOptions): Observable> { return this.communityDataService.findTop({ - currentPage: options.currentPage, - elementsPerPage: this.pageSize, - sort: { - field: options.sort.field, - direction: options.sort.direction - } + currentPage: options.currentPage, + elementsPerPage: this.pageSize, + sort: { + field: options.sort.field, + direction: options.sort.direction, }, - followLink('subcommunities', { findListOptions: this.configOnePage }), - followLink('collections', { findListOptions: this.configOnePage })) + }, + followLink('subcommunities', { findListOptions: this.configOnePage }), + followLink('collections', { findListOptions: this.configOnePage })) .pipe( getFirstSucceededRemoteData(), map((results) => results.payload), @@ -173,9 +199,9 @@ export class CommunityListService { * @param expandedNodes List of expanded nodes; if a node is not expanded its subcommunities and collections need not be added to the list */ public transformListOfCommunities(listOfPaginatedCommunities: PaginatedList, - level: number, - parent: FlatNode, - expandedNodes: FlatNode[]): Observable { + level: number, + parent: FlatNode, + expandedNodes: FlatNode[]): Observable { if (isNotEmpty(listOfPaginatedCommunities.page)) { let currentPage = listOfPaginatedCommunities.currentPage; if (isNotEmpty(parent)) { @@ -186,7 +212,7 @@ export class CommunityListService { return this.transformCommunity(community, level, parent, expandedNodes); }); if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { - obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; + obsList = [...obsList, observableOf([showMoreFlatNode(`community-${uuidv4()}`, level, parent)])]; } return combineAndFlatten(obsList); @@ -222,11 +248,11 @@ export class CommunityListService { let subcoms = []; for (let i = 1; i <= currentCommunityPage; i++) { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { - elementsPerPage: this.pageSize, - currentPage: i - }, - followLink('subcommunities', { findListOptions: this.configOnePage }), - followLink('collections', { findListOptions: this.configOnePage })) + elementsPerPage: this.pageSize, + currentPage: i, + }, + followLink('subcommunities', { findListOptions: this.configOnePage }), + followLink('collections', { findListOptions: this.configOnePage })) .pipe( getFirstCompletedRemoteData(), switchMap((rd: RemoteData>) => { @@ -235,7 +261,7 @@ export class CommunityListService { } else { return observableOf([]); } - }) + }), ); subcoms = [...subcoms, nextSetOfSubcommunitiesPage]; @@ -248,7 +274,7 @@ export class CommunityListService { for (let i = 1; i <= currentCollectionPage; i++) { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: this.pageSize, - currentPage: i + currentPage: i, }) .pipe( getFirstCompletedRemoteData(), @@ -257,7 +283,7 @@ export class CommunityListService { let nodes = rd.payload.page .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { - nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; + nodes = [...nodes, showMoreFlatNode(`collection-${uuidv4()}`, level + 1, communityFlatNode)]; } return nodes; } else { @@ -280,9 +306,7 @@ export class CommunityListService { * @param community Community being checked whether it is expandable (if it has subcommunities or collections) */ public getIsExpandable(community: Community): Observable { - let hasSubcoms$: Observable; - let hasColls$: Observable; - hasSubcoms$ = this.communityDataService.findByParent(community.uuid, this.configOnePage) + const hasSubcoms$ = this.communityDataService.findByParent(community.uuid, this.configOnePage) .pipe( map((rd: RemoteData>) => { if (hasValue(rd) && hasValue(rd.payload)) { @@ -293,7 +317,7 @@ export class CommunityListService { }), ); - hasColls$ = this.collectionDataService.findByParent(community.uuid, this.configOnePage) + const hasColls$ = this.collectionDataService.findByParent(community.uuid, this.configOnePage) .pipe( map((rd: RemoteData>) => { if (hasValue(rd) && hasValue(rd.payload)) { @@ -304,12 +328,9 @@ export class CommunityListService { }), ); - let hasChildren$: Observable; - hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe( - map(([hasSubcoms, hasColls]: [boolean, boolean]) => hasSubcoms || hasColls) + return observableCombineLatest(hasSubcoms$, hasColls$).pipe( + map(([hasSubcoms, hasColls]: [boolean, boolean]) => hasSubcoms || hasColls), ); - - return hasChildren$; } } diff --git a/src/app/community-list-page/community-list.actions.ts b/src/app/community-list-page/community-list.actions.ts index 8e8d6d87cf2..47c72af9f78 100644 --- a/src/app/community-list-page/community-list.actions.ts +++ b/src/app/community-list-page/community-list.actions.ts @@ -1,4 +1,5 @@ import { Action } from '@ngrx/store'; + import { type } from '../shared/ngrx/type'; import { FlatNode } from './flat-node.model'; @@ -7,7 +8,7 @@ import { FlatNode } from './flat-node.model'; */ export const CommunityListActionTypes = { - SAVE: type('dspace/community-list-page/SAVE') + SAVE: type('dspace/community-list-page/SAVE'), }; /** diff --git a/src/app/community-list-page/community-list.reducer.spec.ts b/src/app/community-list-page/community-list.reducer.spec.ts index 0d0f5c75807..abbd16d4cdc 100644 --- a/src/app/community-list-page/community-list.reducer.spec.ts +++ b/src/app/community-list-page/community-list.reducer.spec.ts @@ -1,11 +1,12 @@ import { of as observableOf } from 'rxjs'; + import { buildPaginatedList } from '../core/data/paginated-list.model'; import { Community } from '../core/shared/community.model'; import { PageInfo } from '../core/shared/page-info.model'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { toFlatNode } from './community-list-service'; import { CommunityListSaveAction } from './community-list.actions'; import { CommunityListReducer } from './community-list.reducer'; +import { toFlatNode } from './community-list-service'; describe('communityListReducer', () => { const mockSubcommunities1Page1 = [Object.assign(new Community(), { @@ -20,7 +21,7 @@ describe('communityListReducer', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockSubcommunities1Page1)), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community1', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ); it ('should set init state of the expandedNodes and loadingNode', () => { diff --git a/src/app/community-list-page/community-list.reducer.ts b/src/app/community-list-page/community-list.reducer.ts index 99c8350cf4e..7afcabf067d 100644 --- a/src/app/community-list-page/community-list.reducer.ts +++ b/src/app/community-list-page/community-list.reducer.ts @@ -1,4 +1,8 @@ -import { CommunityListActions, CommunityListActionTypes, CommunityListSaveAction } from './community-list.actions'; +import { + CommunityListActions, + CommunityListActionTypes, + CommunityListSaveAction, +} from './community-list.actions'; import { FlatNode } from './flat-node.model'; /** 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 2a150b313cd..d7293aa559f 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,18 +1,18 @@ - +
- -
- - +
@@ -24,32 +24,37 @@
- + {{ (node.isExpanded ? 'communityList.collapse' : 'communityList.expand') | translate:{ name: dsoNameService.getName(node.payload) } }} + + +
-

- - {{ dsoNameService.getName(node.payload) }} - + + {{ dsoNameService.getName(node.payload) }}   - {{node.payload.archivedItemsCount}} -

+ {{node.payload.archivedItemsCount}} +
- + {{node.payload.shortDescription}} @@ -58,33 +63,29 @@

- - + +
- + {{node.payload.shortDescription}} diff --git a/src/app/community-list-page/community-list/community-list.component.scss b/src/app/community-list-page/community-list/community-list.component.scss new file mode 100644 index 00000000000..2e33380a29f --- /dev/null +++ b/src/app/community-list-page/community-list/community-list.component.scss @@ -0,0 +1,4 @@ +::ng-deep .fa-chevron-right::before { + display: block; + width: 16px; +} 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 ce6b27dbeb2..f997f5db9c3 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,22 +1,46 @@ -import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; - -import { CommunityListComponent } from './community-list.component'; -import { CommunityListService, showMoreFlatNode, toFlatNode } from '../community-list-service'; import { CdkTreeModule } from '@angular/cdk/tree'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterLinkWithHref } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { Community } from '../../core/shared/community.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; + import { buildPaginatedList } from '../../core/data/paginated-list.model'; -import { PageInfo } from '../../core/shared/page-info.model'; 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 { Community } from '../../core/shared/community.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { TruncatableComponent } from '../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { + CommunityListService, + showMoreFlatNode, + toFlatNode, +} from '../community-list-service'; import { FlatNode } from '../flat-node.model'; -import { RouterLinkWithHref } from '@angular/router'; +import { CommunityListComponent } from './community-list.component'; describe('CommunityListComponent', () => { let component: CommunityListComponent; @@ -27,11 +51,11 @@ describe('CommunityListComponent', () => { uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', name: 'subcommunity1', }), - Object.assign(new Community(), { - id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - name: 'subcommunity2', - }) + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + name: 'subcommunity2', + }), ]; const mockCollectionsPage1 = [ Object.assign(new Collection(), { @@ -43,7 +67,7 @@ describe('CommunityListComponent', () => { id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', name: 'collection2', - }) + }), ]; const mockCollectionsPage2 = [ Object.assign(new Collection(), { @@ -55,7 +79,7 @@ describe('CommunityListComponent', () => { id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', name: 'collection4', - }) + }), ]; const mockTopCommunitiesWithChildrenArrays = [ @@ -86,7 +110,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockSubcommunities1Page1)), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community1', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ), toFlatNode( Object.assign(new Community(), { @@ -95,7 +119,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), name: 'community2', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ), toFlatNode( Object.assign(new Community(), { @@ -104,7 +128,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community3', - }), observableOf(false), 0, false, null + }), observableOf(false), 0, false, null, ), ]; let communityListServiceStub; @@ -138,7 +162,7 @@ describe('CommunityListComponent', () => { } if (expandedNodes === null || isEmpty(expandedNodes)) { if (showMoreTopComNode) { - return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode('community', 0, null)]); + return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode(`community-${uuidv4()}`, 0, null)]); } else { return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex)); } @@ -165,42 +189,51 @@ describe('CommunityListComponent', () => { const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; if (subComFlatnodes.length > endSubComIndex) { - flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; + flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, topNode.level + 1, expandedParent)]; } } if (isNotEmpty(collFlatnodes)) { const endColIndex = this.pageSize * expandedParent.currentCollectionPage; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; if (collFlatnodes.length > endColIndex) { - flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; + flatnodes = [...flatnodes, showMoreFlatNode(`collection-${uuidv4()}`, topNode.level + 1, expandedParent)]; } } } } }); if (showMoreTopComNode) { - flatnodes = [...flatnodes, showMoreFlatNode('community', 0, null)]; + flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, 0, null)]; } return observableOf(flatnodes); } - } + }, }; TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock + useClass: TranslateLoaderMock, }, }), CdkTreeModule, RouterTestingModule, - RouterLinkWithHref], - declarations: [CommunityListComponent], + RouterLinkWithHref, + CommunityListComponent, + ], providers: [CommunityListComponent, - { provide: CommunityListService, useValue: communityListServiceStub },], + { provide: CommunityListService, useValue: communityListServiceStub }], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) + .overrideComponent(CommunityListComponent, { + remove: { + imports: [ + ThemedLoadingComponent, + TruncatableComponent, + TruncatablePartComponent, + ] }, + }) .compileComponents(); })); @@ -242,7 +275,7 @@ describe('CommunityListComponent', () => { const showMoreLink = fixture.debugElement.query(By.css('.show-more-node .btn-outline-primary')); showMoreLink.triggerEventHandler('click', { preventDefault: () => {/**/ - } + }, }); tick(); fixture.detectChanges(); @@ -299,12 +332,14 @@ describe('CommunityListComponent', () => { describe('second top community node is expanded and has more children (collections) than page size of collection', () => { describe('children of second top com are added (page-limited pageSize 2)', () => { - let allNodes; + let allNodes: DebugElement[]; beforeEach(fakeAsync(() => { - const chevronExpand = fixture.debugElement.queryAll(By.css('.expandable-node button')); - const chevronExpandSpan = fixture.debugElement.queryAll(By.css('.expandable-node button span')); - if (chevronExpandSpan[1].nativeElement.classList.contains('fa-chevron-right')) { - chevronExpand[1].nativeElement.click(); + const toggleButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.expandable-node button')); + const toggleButtonText: DebugElement = toggleButtons[1].query(By.css('span')); + expect(toggleButtonText).not.toBeNull(); + + if (toggleButtonText.nativeElement.classList.contains('fa-chevron-right')) { + toggleButtons[1].nativeElement.click(); tick(); fixture.detectChanges(); } @@ -314,17 +349,18 @@ describe('CommunityListComponent', () => { allNodes = [...expandableNodesFound, ...childlessNodesFound]; })); it('tree contains 2 (page-limited) top com, 2 (page-limited) coll of 2nd top com, a show more for those page-limited coll and show more for page-limited top com', () => { - mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { - expect(allNodes.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === topFlatnode.name); - })).toBeTruthy(); - }); - mockCollectionsPage1.map((coll) => { - expect(allNodes.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === coll.name); - })).toBeTruthy(); - }); + const allNodeNames: string[] = allNodes.map((node: DebugElement) => node.nativeElement.innerText.trim()); expect(allNodes.length).toEqual(4); + const flatNodes: string[] = mockTopFlatnodesUnexpanded.slice(0, 2).map((flatNode: FlatNode) => flatNode.name); + for (const flatNode of flatNodes) { + expect(allNodeNames).toContain(flatNode); + } + expect(flatNodes.length).toBe(2); + const page1CollectionNames: string[] = mockCollectionsPage1.map((collection: Collection) => collection.name); + for (const collectionName of page1CollectionNames) { + expect(allNodeNames).toContain(collectionName); + } + expect(page1CollectionNames.length).toBe(2); const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node')); expect(showMoreEl.length).toEqual(2); }); 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 90dd6b3c05d..5819471d7e5 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,13 +1,34 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { + CdkTreeModule, + FlatTreeControl, +} from '@angular/cdk/tree'; +import { + AsyncPipe, + NgClass, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { take } from 'rxjs/operators'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CommunityListService} from '../community-list-service'; -import { CommunityListDatasource } from '../community-list-datasource'; -import { FlatTreeControl } from '@angular/cdk/tree'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { + SortDirection, + SortOptions, +} from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/find-list-options.model'; import { isEmpty } from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { TruncatableComponent } from '../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { CommunityListDatasource } from '../community-list-datasource'; +import { CommunityListService } from '../community-list-service'; import { FlatNode } from '../flat-node.model'; -import { FindListOptions } from '../../core/data/find-list-options.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; /** * A tree-structured list of nodes representing the communities, their subCommunities and collections. @@ -17,8 +38,11 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; * Which nodes were expanded is kept in the store, so this persists across pages. */ @Component({ - selector: 'ds-community-list', + selector: 'ds-base-community-list', templateUrl: './community-list.component.html', + styleUrls: ['./community-list.component.scss'], + standalone: true, + imports: [NgIf, ThemedLoadingComponent, CdkTreeModule, NgClass, RouterLink, TruncatableComponent, TruncatablePartComponent, AsyncPipe, TranslateModule], }) export class CommunityListComponent implements OnInit, OnDestroy { @@ -26,7 +50,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { public loadingNode: FlatNode; treeControl = new FlatTreeControl( - (node: FlatNode) => node.level, (node: FlatNode) => true + (node: FlatNode) => node.level, (node: FlatNode) => true, ); dataSource: CommunityListDatasource; paginationConfig: FindListOptions; @@ -84,7 +108,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { toggleExpanded(node: FlatNode) { this.loadingNode = node; if (node.isExpanded) { - this.expandedNodes = this.expandedNodes.filter((node2) => node2.name !== node.name); + this.expandedNodes = this.expandedNodes.filter((node2) => node2.id !== node.id); node.isExpanded = false; } else { this.expandedNodes.push(node); @@ -111,19 +135,18 @@ export class CommunityListComponent implements OnInit, OnDestroy { getNextPage(node: FlatNode): void { this.loadingNode = node; if (node.parent != null) { - if (node.id === 'collection') { + if (node.id.startsWith('collection')) { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCollectionPage++; } - if (node.id === 'community') { + if (node.id.startsWith('community')) { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCommunityPage++; } - this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } else { this.paginationConfig.currentPage++; - this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } } diff --git a/src/app/community-list-page/community-list/themed-community-list.component.ts b/src/app/community-list-page/community-list/themed-community-list.component.ts index 4a986e737c1..5340384ed5a 100644 --- a/src/app/community-list-page/community-list/themed-community-list.component.ts +++ b/src/app/community-list-page/community-list/themed-community-list.component.ts @@ -1,12 +1,15 @@ +import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { CommunityListComponent } from './community-list.component'; -import { Component } from '@angular/core'; @Component({ - selector: 'ds-themed-community-list', + selector: 'ds-community-list', styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityListComponent], }) export class ThemedCommunityListComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/community-list-page/flat-node.model.ts b/src/app/community-list-page/flat-node.model.ts index 0aabbeb4891..125ffc1e597 100644 --- a/src/app/community-list-page/flat-node.model.ts +++ b/src/app/community-list-page/flat-node.model.ts @@ -1,6 +1,7 @@ import { Observable } from 'rxjs'; -import { Community } from '../core/shared/community.model'; + import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model'; /** diff --git a/src/app/community-list-page/themed-community-list-page.component.ts b/src/app/community-list-page/themed-community-list-page.component.ts index 20fa97bedd1..d427b1bec1c 100644 --- a/src/app/community-list-page/themed-community-list-page.component.ts +++ b/src/app/community-list-page/themed-community-list-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CommunityListPageComponent } from './community-list-page.component'; @@ -6,9 +7,11 @@ import { CommunityListPageComponent } from './community-list-page.component'; * Themed wrapper for CommunityListPageComponent */ @Component({ - selector: 'ds-themed-community-list-page', + selector: 'ds-community-list-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityListPageComponent], }) export class ThemedCommunityListPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/community-page/community-form/community-form.component.ts b/src/app/community-page/community-form/community-form.component.ts index fa4809738d9..d32d9e408f2 100644 --- a/src/app/community-page/community-form/community-form.component.ts +++ b/src/app/community-page/community-form/community-form.component.ts @@ -1,19 +1,39 @@ -import { Component, Input, OnChanges, SimpleChange, SimpleChanges } from '@angular/core'; +import { + AsyncPipe, + NgClass, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnChanges, + SimpleChange, + SimpleChanges, +} from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormControlModel, DynamicFormService, DynamicInputModel, - DynamicTextAreaModel + DynamicTextAreaModel, } from '@ng-dynamic-forms/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { environment } from '../../../environments/environment'; +import { AuthService } from '../../core/auth/auth.service'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; import { Community } from '../../core/shared/community.model'; import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; -import { TranslateService } from '@ngx-translate/core'; +import { ComcolPageLogoComponent } from '../../shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { FormComponent } from '../../shared/form/form.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { RequestService } from '../../core/data/request.service'; -import { ObjectCacheService } from '../../core/cache/object-cache.service'; -import { environment } from '../../../environments/environment'; +import { UploaderComponent } from '../../shared/upload/uploader/uploader.component'; +import { VarDirective } from '../../shared/utils/var.directive'; /** * Form used for creating and editing communities @@ -21,7 +41,18 @@ import { environment } from '../../../environments/environment'; @Component({ selector: 'ds-community-form', styleUrls: ['../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.scss'], - templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html' + templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html', + standalone: true, + imports: [ + FormComponent, + TranslateModule, + UploaderComponent, + AsyncPipe, + ComcolPageLogoComponent, + NgIf, + NgClass, + VarDirective, + ], }) export class CommunityFormComponent extends ComColFormComponent implements OnChanges { /** @@ -44,10 +75,10 @@ export class CommunityFormComponent extends ComColFormComponent imple name: 'dc.title', required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'Please enter a name for this title' + required: 'Please enter a name for this title', }, }), new DynamicTextAreaModel({ @@ -78,14 +109,15 @@ export class CommunityFormComponent extends ComColFormComponent imple protected authService: AuthService, protected dsoService: CommunityDataService, protected requestService: RequestService, - protected objectCache: ObjectCacheService) { - super(formService, translate, notificationsService, authService, requestService, objectCache); + protected objectCache: ObjectCacheService, + protected modalService: NgbModal) { + super(formService, translate, notificationsService, authService, requestService, objectCache, modalService); } ngOnChanges(changes: SimpleChanges) { const dsoChange: SimpleChange = changes.dso; if (this.dso && dsoChange && !dsoChange.isFirstChange()) { - super.ngOnInit(); + super.ngOnInit(); } } } diff --git a/src/app/community-page/community-form/community-form.module.ts b/src/app/community-page/community-form/community-form.module.ts deleted file mode 100644 index 925d218973f..00000000000 --- a/src/app/community-page/community-form/community-form.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { CommunityFormComponent } from './community-form.component'; -import { SharedModule } from '../../shared/shared.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { FormModule } from '../../shared/form/form.module'; - -@NgModule({ - imports: [ - ComcolModule, - FormModule, - SharedModule - ], - declarations: [ - CommunityFormComponent, - ], - exports: [ - CommunityFormComponent - ] -}) -export class CommunityFormModule { - -} diff --git a/src/app/community-page/community-page-administrator.guard.ts b/src/app/community-page/community-page-administrator.guard.ts index fd7ce5f7bf0..ecbc9b86c0c 100644 --- a/src/app/community-page/community-page-administrator.guard.ts +++ b/src/app/community-page/community-page-administrator.guard.ts @@ -1,31 +1,16 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Community } from '../core/shared/community.model'; -import { CommunityPageResolver } from './community-page.resolver'; -import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + +import { dsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; -import { AuthService } from '../core/auth/auth.service'; +import { communityPageResolver } from './community-page.resolver'; -@Injectable({ - providedIn: 'root' -}) /** * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights + * Check administrator authorization rights */ -export class CommunityPageAdministratorGuard extends DsoPageSingleFeatureGuard { - constructor(protected resolver: CommunityPageResolver, - protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService) { - super(resolver, authorizationService, router, authService); - } - - /** - * Check administrator authorization rights - */ - getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.AdministratorOf); - } -} +export const communityPageAdministratorGuard: CanActivateFn = + dsoPageSingleFeatureGuard( + () => communityPageResolver, + () => observableOf(FeatureID.AdministratorOf), + ); diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts new file mode 100644 index 00000000000..5d73310ed00 --- /dev/null +++ b/src/app/community-page/community-page-routes.ts @@ -0,0 +1,130 @@ +import { Route } from '@angular/router'; + +import { browseByGuard } from '../browse-by/browse-by-guard'; +import { browseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; +import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; +import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; +import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; +import { MenuItemType } from '../shared/menu/menu-item-type.model'; +import { viewTrackerResolver } from '../statistics/angulartics/dspace/view-tracker.resolver'; +import { communityPageResolver } from './community-page.resolver'; +import { communityPageAdministratorGuard } from './community-page-administrator.guard'; +import { + COMMUNITY_CREATE_PATH, + COMMUNITY_EDIT_PATH, +} from './community-page-routing-paths'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { createCommunityPageGuard } from './create-community-page/create-community-page.guard'; +import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; +import { ThemedCommunityPageComponent } from './themed-community-page.component'; + +export const ROUTES: Route[] = [ + { + path: COMMUNITY_CREATE_PATH, + children: [ + { + path: '', + component: CreateCommunityPageComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + breadcrumbKey: 'community.create', + }, + }, + ], + canActivate: [authenticatedGuard, createCommunityPageGuard], + data: { + breadcrumbQueryParam: 'parent', + }, + resolve: { + breadcrumb: communityBreadcrumbResolver, + }, + runGuardsAndResolvers: 'always', + }, + { + path: ':id', + resolve: { + dso: communityPageResolver, + breadcrumb: communityBreadcrumbResolver, + }, + runGuardsAndResolvers: 'always', + children: [ + { + path: COMMUNITY_EDIT_PATH, + loadChildren: () => import('./edit-community-page/edit-community-page-routes') + .then((m) => m.ROUTES), + canActivate: [communityPageAdministratorGuard], + }, + { + path: 'delete', + pathMatch: 'full', + component: DeleteCommunityPageComponent, + canActivate: [authenticatedGuard], + }, + { + path: '', + component: ThemedCommunityPageComponent, + resolve: { + menu: dsoEditMenuResolver, + tracking: viewTrackerResolver, + }, + children: [ + { + path: '', + pathMatch: 'full', + component: ComcolSearchSectionComponent, + }, + { + path: 'search', + pathMatch: 'full', + component: ComcolSearchSectionComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.search' }, + }, + { + path: 'subcoms-cols', + pathMatch: 'full', + component: SubComColSectionComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.subcoms-cols' }, + }, + { + path: 'browse/:id', + pathMatch: 'full', + component: ComcolBrowseByComponent, + canActivate: [browseByGuard], + resolve: { + breadcrumb: browseByI18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'browse.metadata' }, + }, + ], + }, + ], + data: { + menu: { + public: [{ + id: 'statistics_community_:id', + active: true, + visible: true, + index: 2, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: 'statistics/communities/:id/', + } as LinkMenuItemModel, + }], + }, + }, + }, +]; diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts deleted file mode 100644 index c37f8832f84..00000000000 --- a/src/app/community-page/community-page-routing.module.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { CommunityPageResolver } from './community-page.resolver'; -import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; -import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; -import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; -import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; -import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; -import { LinkService } from '../core/cache/builders/link.service'; -import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; -import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; -import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; -import { ThemedCommunityPageComponent } from './themed-community-page.component'; -import { MenuItemType } from '../shared/menu/menu-item-type.model'; -import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: COMMUNITY_CREATE_PATH, - component: CreateCommunityPageComponent, - canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] - }, - { - path: ':id', - resolve: { - dso: CommunityPageResolver, - breadcrumb: CommunityBreadcrumbResolver, - menu: DSOEditMenuResolver - }, - runGuardsAndResolvers: 'always', - children: [ - { - path: COMMUNITY_EDIT_PATH, - loadChildren: () => import('./edit-community-page/edit-community-page.module') - .then((m) => m.EditCommunityPageModule), - canActivate: [CommunityPageAdministratorGuard] - }, - { - path: 'delete', - pathMatch: 'full', - component: DeleteCommunityPageComponent, - canActivate: [AuthenticatedGuard], - }, - { - path: '', - component: ThemedCommunityPageComponent, - pathMatch: 'full', - } - ], - data: { - menu: { - public: [{ - id: 'statistics_community_:id', - active: true, - visible: true, - index: 2, - model: { - type: MenuItemType.LINK, - text: 'menu.section.statistics', - link: 'statistics/communities/:id/', - } as LinkMenuItemModel, - }], - }, - }, - }, - ]) - ], - providers: [ - CommunityPageResolver, - CommunityBreadcrumbResolver, - DSOBreadcrumbsService, - LinkService, - CreateCommunityPageGuard, - CommunityPageAdministratorGuard, - ] -}) -export class CommunityPageRoutingModule { - -} diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 671bf28fd1c..740a7c8a721 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -1,23 +1,22 @@
-
- + - - + + + [title]="'community.page.news'">
@@ -25,13 +24,12 @@
- - + + - - +
-
+
@@ -40,5 +38,5 @@
- +
diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index a5bbff3cee7..e8a6468dd4c 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -1,32 +1,75 @@ -import { mergeMap, filter, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, + RouterModule, + RouterOutlet, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { CommunityDataService } from '../core/data/community-data.service'; +import { + filter, + map, + mergeMap, +} from 'rxjs/operators'; + +import { AuthService } from '../core/auth/auth.service'; +import { DSONameService } from '../core/breadcrumbs/dso-name.service'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { RemoteData } from '../core/data/remote-data'; +import { redirectOn4xx } from '../core/shared/authorized.operators'; import { Bitstream } from '../core/shared/bitstream.model'; - import { Community } from '../core/shared/community.model'; - -import { MetadataService } from '../core/metadata/metadata.service'; - +import { getAllSucceededRemoteDataPayload } from '../core/shared/operators'; import { fadeInOut } from '../shared/animations/fade'; +import { ThemedComcolPageBrowseByComponent } from '../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; +import { ThemedComcolPageContentComponent } from '../shared/comcol/comcol-page-content/themed-comcol-page-content.component'; +import { ThemedComcolPageHandleComponent } from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; +import { ComcolPageHeaderComponent } from '../shared/comcol/comcol-page-header/comcol-page-header.component'; +import { ComcolPageLogoComponent } from '../shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { DsoEditMenuComponent } from '../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { hasValue } from '../shared/empty.util'; -import { 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 { ErrorComponent } from '../shared/error/error.component'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { VarDirective } from '../shared/utils/var.directive'; import { getCommunityPageRoute } from './community-page-routing-paths'; -import { redirectOn4xx } from '../core/shared/authorized.operators'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; +import { ThemedCollectionPageSubCollectionListComponent } from './sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component'; +import { ThemedCommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component'; @Component({ - selector: 'ds-community-page', + selector: 'ds-base-community-page', styleUrls: ['./community-page.component.scss'], templateUrl: './community-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut] + animations: [fadeInOut], + imports: [ + ThemedComcolPageContentComponent, + ErrorComponent, + ThemedLoadingComponent, + NgIf, + TranslateModule, + ThemedCommunityPageSubCommunityListComponent, + ThemedCollectionPageSubCollectionListComponent, + ThemedComcolPageBrowseByComponent, + DsoEditMenuComponent, + ThemedComcolPageHandleComponent, + ComcolPageLogoComponent, + ComcolPageHeaderComponent, + AsyncPipe, + VarDirective, + RouterOutlet, + RouterModule, + ], + standalone: true, }) /** * This component represents a detail page for a single community @@ -53,8 +96,6 @@ export class CommunityPageComponent implements OnInit { communityPageRoute$: Observable; constructor( - private communityDataService: CommunityDataService, - private metadata: MetadataService, private route: ActivatedRoute, private router: Router, private authService: AuthService, @@ -67,7 +108,7 @@ export class CommunityPageComponent implements OnInit { ngOnInit(): void { this.communityRD$ = this.route.data.pipe( map((data) => data.dso as RemoteData), - redirectOn4xx(this.router, this.authService) + redirectOn4xx(this.router, this.authService), ); this.logoRD$ = this.communityRD$.pipe( map((rd: RemoteData) => rd.payload), @@ -75,7 +116,7 @@ export class CommunityPageComponent implements OnInit { mergeMap((community: Community) => community.logo)); this.communityPageRoute$ = this.communityRD$.pipe( getAllSucceededRemoteDataPayload(), - map((community) => getCommunityPageRoute(community.id)) + map((community) => getCommunityPageRoute(community.id)), ); this.isCommunityAdmin$ = this.authorizationDataService.isAuthorized(FeatureID.IsCommunityAdmin); } diff --git a/src/app/community-page/community-page.module.ts b/src/app/community-page/community-page.module.ts deleted file mode 100644 index 45ffb2a7868..00000000000 --- a/src/app/community-page/community-page.module.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { SharedModule } from '../shared/shared.module'; - -import { CommunityPageComponent } from './community-page.component'; -import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; -import { CommunityPageRoutingModule } from './community-page-routing.module'; -import { CommunityPageSubCommunityListComponent } from './sub-community-list/community-page-sub-community-list.component'; -import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; -import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; -import { StatisticsModule } from '../statistics/statistics.module'; -import { CommunityFormModule } from './community-form/community-form.module'; -import { ThemedCommunityPageComponent } from './themed-community-page.component'; -import { ComcolModule } from '../shared/comcol/comcol.module'; -import { - ThemedCommunityPageSubCommunityListComponent -} from './sub-community-list/themed-community-page-sub-community-list.component'; -import { - ThemedCollectionPageSubCollectionListComponent -} from './sub-collection-list/themed-community-page-sub-collection-list.component'; -import { DsoPageModule } from '../shared/dso-page/dso-page.module'; - -const DECLARATIONS = [CommunityPageComponent, - ThemedCommunityPageComponent, - ThemedCommunityPageSubCommunityListComponent, - CommunityPageSubCollectionListComponent, - ThemedCollectionPageSubCollectionListComponent, - CommunityPageSubCommunityListComponent, - CreateCommunityPageComponent, - DeleteCommunityPageComponent]; - -@NgModule({ - imports: [ - CommonModule, - SharedModule, - CommunityPageRoutingModule, - StatisticsModule.forRoot(), - CommunityFormModule, - ComcolModule, - DsoPageModule, - ], - declarations: [ - ...DECLARATIONS - ], - exports: [ - ...DECLARATIONS - ] -}) - -export class CommunityPageModule { - -} diff --git a/src/app/community-page/community-page.resolver.spec.ts b/src/app/community-page/community-page.resolver.spec.ts index f181dbfff65..e429ecf17c6 100644 --- a/src/app/community-page/community-page.resolver.spec.ts +++ b/src/app/community-page/community-page.resolver.spec.ts @@ -1,32 +1,34 @@ +import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { CommunityPageResolver } from './community-page.resolver'; + import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { communityPageResolver } from './community-page.resolver'; -describe('CommunityPageResolver', () => { +describe('communityPageResolver', () => { describe('resolve', () => { - let resolver: CommunityPageResolver; + let resolver: any; let communityService: any; let store: any; const uuid = '1234-65487-12354-1235'; beforeEach(() => { communityService = { - findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findById: (id: string) => createSuccessfulRemoteDataObject$({ id }), }; store = jasmine.createSpyObj('store', { dispatch: {}, }); - resolver = new CommunityPageResolver(communityService, store); + resolver = communityPageResolver; }); it('should resolve a community with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, { url: 'current-url' } as any) + (resolver({ params: { id: uuid } } as any, { url: 'current-url' } as any, communityService, store) as Observable) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/community-page/community-page.resolver.ts b/src/app/community-page/community-page.resolver.ts index 01de9294f37..b8820629e78 100644 --- a/src/app/community-page/community-page.resolver.ts +++ b/src/app/community-page/community-page.resolver.ts @@ -1,13 +1,22 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; + +import { AppState } from '../app.reducer'; +import { CommunityDataService } from '../core/data/community-data.service'; import { RemoteData } from '../core/data/remote-data'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; import { Community } from '../core/shared/community.model'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; -import { ResolvedAction } from '../core/resolving/resolver.actions'; -import { Store } from '@ngrx/store'; +import { + followLink, + FollowLinkConfig, +} from '../shared/utils/follow-link-config.model'; /** * The self links defined in this list are expected to be requested somewhere in the near future @@ -17,41 +26,36 @@ export const COMMUNITY_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ followLink('logo'), followLink('subcommunities'), followLink('collections'), - followLink('parentCommunity') + followLink('parentCommunity'), ]; /** - * This class represents a resolver that requests a specific community before the route is activated + * Method for resolving a community based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {CommunityDataService} communityService + * @param {Store} store + * @returns Observable<> Emits the found community based on the parameters in the current route, + * or an error if something went wrong */ -@Injectable() -export class CommunityPageResolver implements Resolve> { - constructor( - private communityService: CommunityDataService, - private store: Store - ) { - } - - /** - * Method for resolving a community based on the parameters in the current route - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found community based on the parameters in the current route, - * or an error if something went wrong - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - const communityRD$ = this.communityService.findById( - route.params.id, - true, - false, - ...COMMUNITY_PAGE_LINKS_TO_FOLLOW - ).pipe( - getFirstCompletedRemoteData(), - ); +export const communityPageResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + communityService: CommunityDataService = inject(CommunityDataService), + store: Store = inject(Store), +): Observable> => { + const communityRD$ = communityService.findById( + route.params.id, + true, + false, + ...COMMUNITY_PAGE_LINKS_TO_FOLLOW, + ).pipe( + getFirstCompletedRemoteData(), + ); - communityRD$.subscribe((communityRD: RemoteData) => { - this.store.dispatch(new ResolvedAction(state.url, communityRD.payload)); - }); + communityRD$.subscribe((communityRD: RemoteData) => { + store.dispatch(new ResolvedAction(state.url, communityRD.payload)); + }); - return communityRD$; - } -} + return communityRD$; +}; diff --git a/src/app/community-page/create-community-page/create-community-page.component.html b/src/app/community-page/create-community-page/create-community-page.component.html index 57039040c2a..1d3d6eb871b 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.html +++ b/src/app/community-page/create-community-page/create-community-page.component.html @@ -1,13 +1,18 @@ -
+
- -

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

+

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

+

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

+ [isCreation]="true" + (back)="navigateToHome()"> +
+ +
+
diff --git a/src/app/community-page/create-community-page/create-community-page.component.spec.ts b/src/app/community-page/create-community-page/create-community-page.component.spec.ts index fbff82efd86..062c0ea0620 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.spec.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.spec.ts @@ -1,17 +1,24 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RouteService } from '../../core/services/route.service'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { CreateCommunityPageComponent } from './create-community-page.component'; +import { RequestService } from '../../core/data/request.service'; +import { RouteService } from '../../core/services/route.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { RequestService } from '../../core/data/request.service'; +import { CommunityFormComponent } from '../community-form/community-form.component'; +import { CreateCommunityPageComponent } from './create-community-page.component'; describe('CreateCommunityPageComponent', () => { let comp: CreateCommunityPageComponent; @@ -19,17 +26,23 @@ describe('CreateCommunityPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [CreateCommunityPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, CreateCommunityPageComponent], providers: [ { provide: CommunityDataService, useValue: { findById: () => observableOf({}) } }, { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, - { provide: RequestService, useValue: {} } + { provide: RequestService, useValue: {} }, + { provide: AuthService, useValue: new AuthServiceMock() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(CreateCommunityPageComponent, { + remove: { + imports: [CommunityFormComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/community-page/create-community-page/create-community-page.component.ts b/src/app/community-page/create-community-page/create-community-page.component.ts index eea09083887..082f6c4f0b4 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.ts @@ -1,13 +1,24 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; +import { Router } from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; import { RouteService } from '../../core/services/route.service'; -import { Router } from '@angular/router'; +import { Community } from '../../core/shared/community.model'; import { CreateComColPageComponent } from '../../shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { CommunityFormComponent } from '../community-form/community-form.component'; /** * Component that represents the page where a user can create a new Community @@ -15,7 +26,16 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-create-community', styleUrls: ['./create-community-page.component.scss'], - templateUrl: './create-community-page.component.html' + templateUrl: './create-community-page.component.html', + imports: [ + CommunityFormComponent, + TranslateModule, + VarDirective, + NgIf, + AsyncPipe, + ThemedLoadingComponent, + ], + standalone: true, }) export class CreateCommunityPageComponent extends CreateComColPageComponent { protected frontendURL = '/communities/'; @@ -28,7 +48,7 @@ export class CreateCommunityPageComponent extends CreateComColPageComponent { +import { Community } from '../../core/shared/community.model'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { createCommunityPageGuard } from './create-community-page.guard'; + +describe('createCommunityPageGuard', () => { describe('canActivate', () => { - let guard: CreateCommunityPageGuard; + let guard: any; let router; let communityDataServiceStub: any; @@ -20,46 +24,46 @@ describe('CreateCommunityPageGuard', () => { } else if (id === 'error-id') { return createFailedRemoteDataObject$('not found', 404); } - } + }, }; router = new RouterMock(); - guard = new CreateCommunityPageGuard(router, communityDataServiceStub); + guard = createCommunityPageGuard; }); it('should return true when the parent ID resolves to a community', () => { - guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined) + guard({ queryParams: { parent: 'valid-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); it('should return true when no parent ID has been provided', () => { - guard.canActivate({ queryParams: { } } as any, undefined) + guard({ queryParams: { } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); it('should return false when the parent ID does not resolve to a community', () => { - guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined) + guard({ queryParams: { parent: 'invalid-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); it('should return false when the parent ID resolves to an error response', () => { - guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined) + guard({ queryParams: { parent: 'error-id' } } as any, undefined, communityDataServiceStub, router) .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); }); diff --git a/src/app/community-page/create-community-page/create-community-page.guard.ts b/src/app/community-page/create-community-page/create-community-page.guard.ts index 835fbb65891..c3ee8c70914 100644 --- a/src/app/community-page/create-community-page/create-community-page.guard.ts +++ b/src/app/community-page/create-community-page/create-community-page.guard.ts @@ -1,44 +1,52 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + tap, +} from 'rxjs/operators'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { map, tap } from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; /** - * Prevent creation of a community with an invalid parent community provided - * @class CreateCommunityPageGuard + * True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community + * Reroutes to a 404 page when the page cannot be activated */ -@Injectable() -export class CreateCommunityPageGuard implements CanActivate { - public constructor(private router: Router, private communityService: CommunityDataService) { +export const createCommunityPageGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + communityService: CommunityDataService = inject(CommunityDataService), + router: Router = inject(Router), +): Observable => { + const parentID = route.queryParams.parent; + if (hasNoValue(parentID)) { + return observableOf(true); } - /** - * True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community - * Reroutes to a 404 page when the page cannot be activated - * @method canActivate - */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const parentID = route.queryParams.parent; - if (hasNoValue(parentID)) { - return observableOf(true); - } - - return this.communityService.findById(parentID) - .pipe( - getFirstCompletedRemoteData(), - map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), - tap((isValid: boolean) => { - if (!isValid) { - this.router.navigate(['/404']); - } + return communityService.findById(parentID) + .pipe( + getFirstCompletedRemoteData(), + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + router.navigate(['/404']); } - ) + }, + ), ); - } -} +}; diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.html b/src/app/community-page/delete-community-page/delete-community-page.component.html index 6bb8460bc95..b5d215e3b6c 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 @@ -2,16 +2,16 @@
- +

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

{{ 'community.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}

- -
diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts b/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts index 55d0508c103..524f3e31243 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts @@ -1,17 +1,21 @@ import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SharedModule } from '../../shared/shared.module'; -import { DeleteCommunityPageComponent } from './delete-community-page.component'; import { RequestService } from '../../core/data/request.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DeleteCommunityPageComponent } from './delete-community-page.component'; describe('DeleteCommunityPageComponent', () => { let comp: DeleteCommunityPageComponent; @@ -19,16 +23,15 @@ describe('DeleteCommunityPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [DeleteCommunityPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, DeleteCommunityPageComponent], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, - { provide: RequestService, useValue: {}} + { provide: RequestService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.ts b/src/app/community-page/delete-community-page/delete-community-page.component.ts index 65b7c81b38b..9c19a5eb472 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.ts @@ -1,11 +1,24 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Community } from '../../core/shared/community.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { VarDirective } from '../../shared/utils/var.directive'; /** * Component that represents the page where a user can delete an existing Community @@ -13,7 +26,15 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-delete-community', styleUrls: ['./delete-community-page.component.scss'], - templateUrl: './delete-community-page.component.html' + templateUrl: './delete-community-page.component.html', + imports: [ + TranslateModule, + AsyncPipe, + VarDirective, + NgIf, + BtnDisabledDirective, + ], + standalone: true, }) export class DeleteCommunityPageComponent extends DeleteComColPageComponent { protected frontendURL = '/communities/'; diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts index d895cfd820b..28879ed7abf 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts @@ -1,25 +1,80 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + of as observableOf, + of, +} from 'rxjs'; +import { Community } from '../../../core/shared/community.model'; +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { CommunityAccessControlComponent } from './community-access-control.component'; -xdescribe('CommunityAccessControlComponent', () => { +describe('CommunityAccessControlComponent', () => { let component: CommunityAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }], + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID', + }, + }, + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity), + }), + }, + }, + }; + + beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CommunityAccessControlComponent ] + imports: [CommunityAccessControlComponent], + providers: [{ + provide: ActivatedRoute, useValue: routeStub, + }], }) - .compileComponents(); + .overrideComponent(CommunityAccessControlComponent, { + remove: { + imports: [AccessControlFormContainerComponent], + }, + }) + .compileComponents(); }); + beforeEach(() => { fixture = TestBed.createComponent(CommunityAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts index 8a216e38dfa..a0e094e21d4 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts @@ -1,15 +1,30 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; + +import { RemoteData } from '../../../core/data/remote-data'; import { Community } from '../../../core/shared/community.model'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; @Component({ selector: 'ds-community-access-control', templateUrl: './community-access-control.component.html', styleUrls: ['./community-access-control.component.scss'], + imports: [ + AccessControlFormContainerComponent, + NgIf, + AsyncPipe, + ], + standalone: true, }) export class CommunityAccessControlComponent implements OnInit { itemRD$: Observable>; @@ -18,7 +33,7 @@ export class CommunityAccessControlComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.parent.parent.data.pipe( - map((data) => data.dso) + map((data) => data.dso), ).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts index 719cf83a26f..921bbf0cfda 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts @@ -1,15 +1,22 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; - import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; +import { Collection } from '../../../core/shared/collection.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ResourcePoliciesComponent } from '../../../shared/resource-policies/resource-policies.component'; import { CommunityAuthorizationsComponent } from './community-authorizations.component'; -import { Collection } from '../../../core/shared/collection.model'; describe('CommunityAuthorizationsComponent', () => { let comp: CommunityAuthorizationsComponent; @@ -19,8 +26,8 @@ describe('CommunityAuthorizationsComponent', () => { uuid: 'community', id: 'community', _links: { - self: { href: 'community-selflink' } - } + self: { href: 'community-selflink' }, + }, }); const communityRD = createSuccessfulRemoteDataObject(community); @@ -29,25 +36,31 @@ describe('CommunityAuthorizationsComponent', () => { parent: { parent: { data: observableOf({ - dso: communityRD - }) - } - } + dso: communityRD, + }), + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - CommonModule + CommonModule, + CommunityAuthorizationsComponent, ], - declarations: [CommunityAuthorizationsComponent], providers: [ { provide: ActivatedRoute, useValue: routeStub }, ChangeDetectorRef, CommunityAuthorizationsComponent, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); + }) + .overrideComponent(CommunityAuthorizationsComponent, { + remove: { + imports: [ResourcePoliciesComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts index 7a9f224311d..3e42a830bef 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts @@ -1,13 +1,27 @@ -import { Component, OnInit } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { ResourcePoliciesComponent } from '../../../shared/resource-policies/resource-policies.component'; @Component({ selector: 'ds-community-authorizations', templateUrl: './community-authorizations.component.html', + imports: [ + ResourcePoliciesComponent, + AsyncPipe, + ], + standalone: true, }) /** * Component that handles the community Authorizations @@ -25,7 +39,7 @@ export class CommunityAuthorizationsComponent impl * @param {ActivatedRoute} route */ constructor( - private route: ActivatedRoute + private route: ActivatedRoute, ) { } diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.html b/src/app/community-page/edit-community-page/community-curate/community-curate.component.html index 6c041d17253..5e11fdfbcea 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.html +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.html @@ -1,5 +1,5 @@
-

{{'community.curate.header' |translate:{community: (communityName$ |async)} }}

+

{{'community.curate.header' |translate:{community: (communityName$ |async)} }}

diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts b/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts index 1b1ee2c9f95..541308c9424 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts @@ -1,12 +1,21 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { ActivatedRoute } from '@angular/router'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { CommunityCurateComponent } from './community-curate.component'; import { Community } from '../../../core/shared/community.model'; +import { CurationFormComponent } from '../../../curation-form/curation-form.component'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { CommunityCurateComponent } from './community-curate.component'; describe('CommunityCurateComponent', () => { let comp: CommunityCurateComponent; @@ -17,31 +26,36 @@ describe('CommunityCurateComponent', () => { let dsoNameService; const community = Object.assign(new Community(), { - metadata: {'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} + metadata: { 'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1' }] }, }); beforeEach(waitForAsync(() => { routeStub = { parent: { data: observableOf({ - dso: createSuccessfulRemoteDataObject(community) - }) - } + dso: createSuccessfulRemoteDataObject(community), + }), + }, }; dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: 'Community Name' + getName: 'Community Name', }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [CommunityCurateComponent], + imports: [TranslateModule.forRoot(), CommunityCurateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: DSONameService, useValue: dsoNameService} + { provide: ActivatedRoute, useValue: routeStub }, + { provide: DSONameService, useValue: dsoNameService }, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }).compileComponents(); + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(CommunityCurateComponent, { + remove: { + imports: [CurationFormComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -58,7 +72,7 @@ describe('CommunityCurateComponent', () => { }); it('should contain the community information provided in the route', () => { comp.dsoRD$.subscribe((value) => { - expect(value.payload.handle + expect(value.payload.handle, ).toEqual('123456789/1'); }); comp.communityName$.subscribe((value) => { diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts b/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts index 8ae04af8f15..fd4d2408278 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts @@ -1,10 +1,21 @@ -import { Component, OnInit } from '@angular/core'; -import { Community } from '../../../core/shared/community.model'; +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { filter, map, take } from 'rxjs/operators'; -import { RemoteData } from '../../../core/data/remote-data'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; +import { CurationFormComponent } from '../../../curation-form/curation-form.component'; import { hasValue } from '../../../shared/empty.util'; /** @@ -13,6 +24,12 @@ import { hasValue } from '../../../shared/empty.util'; @Component({ selector: 'ds-community-curate', templateUrl: './community-curate.component.html', + imports: [ + CurationFormComponent, + TranslateModule, + AsyncPipe, + ], + standalone: true, }) export class CommunityCurateComponent implements OnInit { @@ -35,7 +52,7 @@ export class CommunityCurateComponent implements OnInit { filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { return this.dsoNameService.getName(rd.payload); - }) + }), ); } diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.html b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.html index 2ca5b768e4d..bf75944242f 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.html +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.html @@ -1,5 +1,5 @@ - diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts index c597fac0bd3..b82beaa3f73 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts @@ -1,15 +1,20 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../shared/shared.module'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CommunityMetadataComponent } from './community-metadata.component'; + import { CommunityDataService } from '../../../core/data/community-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { CommunityFormComponent } from '../../community-form/community-form.component'; +import { CommunityMetadataComponent } from './community-metadata.component'; describe('CommunityMetadataComponent', () => { let comp: CommunityMetadataComponent; @@ -17,15 +22,20 @@ describe('CommunityMetadataComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [CommunityMetadataComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, CommunityMetadataComponent], providers: [ { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: { payload: {} } }) } } }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() } + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(CommunityMetadataComponent, { + remove: { + imports: [CommunityFormComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts index a2dbfa6eb61..8001bd29697 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts @@ -1,10 +1,16 @@ +import { AsyncPipe } from '@angular/common'; import { Component } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Community } from '../../../core/shared/community.model'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + import { CommunityDataService } from '../../../core/data/community-data.service'; +import { Community } from '../../../core/shared/community.model'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; +import { CommunityFormComponent } from '../../community-form/community-form.component'; /** * Component for editing a community's metadata @@ -12,6 +18,11 @@ import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'ds-community-metadata', templateUrl: './community-metadata.component.html', + imports: [ + CommunityFormComponent, + AsyncPipe, + ], + standalone: true, }) export class CommunityMetadataComponent extends ComcolMetadataComponent { protected frontendURL = '/communities/'; @@ -22,7 +33,7 @@ export class CommunityMetadataComponent extends ComcolMetadataComponent { @@ -39,10 +47,10 @@ describe('CommunityRolesComponent', () => { href: 'adminGroup link', }, }, - }) + }), ), - }) - } + }), + }, }; const requestService = { @@ -55,13 +63,9 @@ describe('CommunityRolesComponent', () => { TestBed.configureTestingModule({ imports: [ - ComcolModule, - SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), - NoopAnimationsModule - ], - declarations: [ + NoopAnimationsModule, CommunityRolesComponent, ], providers: [ @@ -69,9 +73,9 @@ describe('CommunityRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, - { provide: NotificationsService, useClass: NotificationsServiceStub } + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(CommunityRolesComponent); diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts index 9468aa7048b..2e85cbe4c36 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts @@ -1,11 +1,26 @@ -import { Component, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgForOf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; -import { Community } from '../../../core/shared/community.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; import { HALLink } from '../../../core/shared/hal-link.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; +import { ComcolRoleComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component'; /** * Component for managing a community's roles @@ -13,6 +28,12 @@ import { HALLink } from '../../../core/shared/hal-link.model'; @Component({ selector: 'ds-community-roles', templateUrl: './community-roles.component.html', + imports: [ + ComcolRoleComponent, + AsyncPipe, + NgForOf, + ], + standalone: true, }) export class CommunityRolesComponent implements OnInit { diff --git a/src/app/community-page/edit-community-page/edit-community-page-routes.ts b/src/app/community-page/edit-community-page/edit-community-page-routes.ts new file mode 100644 index 00000000000..2402c2037d5 --- /dev/null +++ b/src/app/community-page/edit-community-page/edit-community-page-routes.ts @@ -0,0 +1,88 @@ +import { Route } from '@angular/router'; + +import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { communityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { resourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { resourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; +import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; +import { CommunityCurateComponent } from './community-curate/community-curate.component'; +import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; +import { CommunityRolesComponent } from './community-roles/community-roles.component'; +import { EditCommunityPageComponent } from './edit-community-page.component'; + +/** + * Routing module that handles the routing for the Edit Community page administrator functionality + */ + +export const ROUTES: Route[] = [ + { + path: '', + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.edit' }, + component: EditCommunityPageComponent, + canActivate: [communityAdministratorGuard], + children: [ + { + path: '', + redirectTo: 'metadata', + pathMatch: 'full', + }, + { + path: 'metadata', + component: CommunityMetadataComponent, + data: { + title: 'community.edit.tabs.metadata.title', + hideReturnButton: true, + showBreadcrumbs: true, + }, + }, + { + path: 'roles', + component: CommunityRolesComponent, + data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true }, + }, + { + path: 'curate', + component: CommunityCurateComponent, + data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true }, + }, + { + path: 'access-control', + component: CommunityAccessControlComponent, + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true }, + }, + { + path: 'authorizations', + data: { showBreadcrumbs: true }, + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: resourcePolicyTargetResolver, + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title' }, + }, + { + path: 'edit', + resolve: { + resourcePolicy: resourcePolicyResolver, + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title' }, + }, + { + path: '', + component: CommunityAuthorizationsComponent, + data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true, hideReturnButton: true }, + }, + ], + }, + ], + }, +]; diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts b/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts index 3a4c3351c32..f099f6fc2af 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts @@ -1,13 +1,17 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { EditCommunityPageComponent } from './edit-community-page.component'; + import { CommunityDataService } from '../../core/data/community-data.service'; +import { EditCommunityPageComponent } from './edit-community-page.component'; describe('EditCommunityPageComponent', () => { let comp: EditCommunityPageComponent; @@ -15,36 +19,35 @@ describe('EditCommunityPageComponent', () => { const routeStub = { data: observableOf({ - dso: { payload: {} } + dso: { payload: {} }, }), routeConfig: { children: [ { path: 'mockUrl', data: { - hideReturnButton: false - } - } - ] + hideReturnButton: false, + }, + }, + ], }, snapshot: { firstChild: { routeConfig: { - path: 'mockUrl' - } - } - } + path: 'mockUrl', + }, + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], - declarations: [EditCommunityPageComponent], + imports: [TranslateModule.forRoot(), CommonModule, RouterTestingModule, EditCommunityPageComponent], providers: [ { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: routeStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.ts b/src/app/community-page/edit-community-page/edit-community-page.component.ts index 54a6ee49442..194976c3a8d 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.ts @@ -1,6 +1,19 @@ +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; +import { + ActivatedRoute, + Router, + RouterLink, + RouterOutlet, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Community } from '../../core/shared/community.model'; -import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { getCommunityPageRoute } from '../community-page-routing-paths'; @@ -9,14 +22,24 @@ import { getCommunityPageRoute } from '../community-page-routing-paths'; */ @Component({ selector: 'ds-edit-community', - templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html', + standalone: true, + imports: [ + RouterLink, + TranslateModule, + NgClass, + NgForOf, + RouterOutlet, + NgIf, + AsyncPipe, + ], }) export class EditCommunityPageComponent extends EditComColPageComponent { type = 'community'; public constructor( protected router: Router, - protected route: ActivatedRoute + protected route: ActivatedRoute, ) { super(router, route); } diff --git a/src/app/community-page/edit-community-page/edit-community-page.module.ts b/src/app/community-page/edit-community-page/edit-community-page.module.ts deleted file mode 100644 index 5190d6a0083..00000000000 --- a/src/app/community-page/edit-community-page/edit-community-page.module.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { SharedModule } from '../../shared/shared.module'; -import { EditCommunityPageRoutingModule } from './edit-community-page.routing.module'; -import { EditCommunityPageComponent } from './edit-community-page.component'; -import { CommunityCurateComponent } from './community-curate/community-curate.component'; -import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; -import { CommunityRolesComponent } from './community-roles/community-roles.component'; -import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; -import { CommunityFormModule } from '../community-form/community-form.module'; -import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; -import { - AccessControlFormModule -} from '../../shared/access-control-form-container/access-control-form.module'; - -/** - * Module that contains all components related to the Edit Community page administrator functionality - */ -@NgModule({ - imports: [ - CommonModule, - SharedModule, - EditCommunityPageRoutingModule, - CommunityFormModule, - ComcolModule, - ResourcePoliciesModule, - AccessControlFormModule, - ], - declarations: [ - EditCommunityPageComponent, - CommunityCurateComponent, - CommunityMetadataComponent, - CommunityRolesComponent, - CommunityAuthorizationsComponent, - CommunityAccessControlComponent - ] -}) -export class EditCommunityPageModule { - -} diff --git a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts b/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts deleted file mode 100644 index 994c6b5e961..00000000000 --- a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { EditCommunityPageComponent } from './edit-community-page.component'; -import { RouterModule } from '@angular/router'; -import { NgModule } from '@angular/core'; -import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; -import { CommunityRolesComponent } from './community-roles/community-roles.component'; -import { CommunityCurateComponent } from './community-curate/community-curate.component'; -import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; -import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; -import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; -import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; -import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; -import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; -import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; - -/** - * Routing module that handles the routing for the Edit Community page administrator functionality - */ -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: '', - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { breadcrumbKey: 'community.edit' }, - component: EditCommunityPageComponent, - canActivate: [CommunityAdministratorGuard], - children: [ - { - path: '', - redirectTo: 'metadata', - pathMatch: 'full' - }, - { - path: 'metadata', - component: CommunityMetadataComponent, - data: { - title: 'community.edit.tabs.metadata.title', - hideReturnButton: true, - showBreadcrumbs: true - } - }, - { - path: 'roles', - component: CommunityRolesComponent, - data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true } - }, - { - path: 'curate', - component: CommunityCurateComponent, - data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true } - }, - { - path: 'access-control', - component: CommunityAccessControlComponent, - data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } - }, - /*{ - path: 'authorizations', - component: CommunityAuthorizationsComponent, - data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true } - },*/ - { - path: 'authorizations', - data: { showBreadcrumbs: true }, - children: [ - { - path: 'create', - resolve: { - resourcePolicyTarget: ResourcePolicyTargetResolver - }, - component: ResourcePolicyCreateComponent, - data: { title: 'resource-policies.create.page.title' } - }, - { - path: 'edit', - resolve: { - resourcePolicy: ResourcePolicyResolver - }, - component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title' } - }, - { - path: '', - component: CommunityAuthorizationsComponent, - data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true, hideReturnButton: true } - } - ] - } - ] - } - ]) - ], - providers: [ - ResourcePolicyResolver, - ResourcePolicyTargetResolver - ] -}) -export class EditCommunityPageRoutingModule { - -} diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html similarity index 71% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html index 69f16ee3ac0..59d7b3bb5e2 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html @@ -1,6 +1,6 @@
-

{{'community.sub-collection-list.head' | translate}}

+

{{'community.sub-collection-list.head' | translate}}

{{'community.sub-collection-list.head' | translate}}

- + diff --git a/src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.scss similarity index 100% rename from src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.scss diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts new file mode 100644 index 00000000000..dc4ab520812 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -0,0 +1,206 @@ +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { LinkHeadService } from '../../../../core/services/link-head.service'; +import { Community } from '../../../../core/shared/community.model'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { HostWindowService } from '../../../../shared/host-window.service'; +import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; +import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { ThemeService } from '../../../../shared/theme-support/theme.service'; +import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; + +describe('CommunityPageSubCollectionListComponent', () => { + let comp: CommunityPageSubCollectionListComponent; + let fixture: ComponentFixture; + let collectionDataServiceStub: any; + let themeService; + let subCollList = []; + + const collections = [Object.assign(new Community(), { + id: '123456789-1', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 1' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 2' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-3', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 3' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-4', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 4' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-5', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 5' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-6', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 6' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-7', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 7' }, + ], + }, + }), + ]; + + const mockCommunity = Object.assign(new Community(), { + id: '123456789', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' }, + ], + }, + }); + + collectionDataServiceStub = { + findByParent(parentUUID: string, options: FindListOptions = {}) { + let currentPage = options.currentPage; + let elementsPerPage = options.elementsPerPage; + if (currentPage === undefined) { + currentPage = 1; + } + elementsPerPage = 5; + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > subCollList.length) { + endPageIndex = subCollList.length; + } + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCollList.slice(startPageIndex, endPageIndex))); + + }, + }; + + const paginationService = new PaginationServiceStub(); + + 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: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]), + NgbModule, + NoopAnimationsModule, + CommunityPageSubCollectionListComponent, + ], + providers: [ + { provide: CollectionDataService, useValue: collectionDataServiceStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, + { 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(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityPageSubCollectionListComponent); + comp = fixture.componentInstance; + comp.community = mockCommunity; + }); + + + it('should display a list of collections', async () => { + subCollList = collections; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const collList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(collList.length).toEqual(5); + expect(collList[0].nativeElement.textContent).toContain('Collection 1'); + expect(collList[1].nativeElement.textContent).toContain('Collection 2'); + expect(collList[2].nativeElement.textContent).toContain('Collection 3'); + expect(collList[3].nativeElement.textContent).toContain('Collection 4'); + expect(collList[4].nativeElement.textContent).toContain('Collection 5'); + }); + + it('should not display the header when list of collections is empty', () => { + subCollList = []; + fixture.detectChanges(); + + const subComHead = fixture.debugElement.queryAll(By.css('h2')); + expect(subComHead.length).toEqual(0); + }); +}); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts new file mode 100644 index 00000000000..1e8ff1d46c3 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts @@ -0,0 +1,130 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Subscription, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + SortDirection, + SortOptions, +} from '../../../../core/cache/models/sort-options.model'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { Collection } from '../../../../core/shared/collection.model'; +import { Community } from '../../../../core/shared/community.model'; +import { fadeIn } from '../../../../shared/animations/fade'; +import { hasValue } from '../../../../shared/empty.util'; +import { ErrorComponent } from '../../../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../shared/object-collection/object-collection.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { VarDirective } from '../../../../shared/utils/var.directive'; + +@Component({ + selector: 'ds-base-community-page-sub-collection-list', + styleUrls: ['./community-page-sub-collection-list.component.scss'], + templateUrl: './community-page-sub-collection-list.component.html', + animations: [fadeIn], + imports: [ + ObjectCollectionComponent, + ErrorComponent, + ThemedLoadingComponent, + NgIf, + TranslateModule, + AsyncPipe, + VarDirective, + ], + standalone: true, +}) +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 + */ + config: PaginationComponentOptions; + + /** + * The pagination id + */ + pageId = 'cmcl'; + + /** + * The sorting configuration + */ + sortConfig: SortOptions; + + /** + * A list of remote data objects of communities' collections + */ + subCollectionsRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + + subscriptions: Subscription[] = []; + + constructor( + protected cds: CollectionDataService, + protected paginationService: PaginationService, + protected route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.config = new PaginationComponentOptions(); + this.config.id = this.pageId; + if (hasValue(this.pageSize)) { + this.config.pageSize = this.pageSize; + } else { + this.config.pageSize = this.route.snapshot.queryParams[this.pageId + '.rpp'] ?? this.config.pageSize; + } + this.config.currentPage = this.route.snapshot.queryParams[this.pageId + '.page'] ?? 1; + this.sortConfig = new SortOptions('dc.title', SortDirection[this.route.snapshot.queryParams[this.pageId + '.sd']] ?? SortDirection.ASC); + this.initPage(); + } + + /** + * Initialise the list of collections + */ + initPage() { + const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); + const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); + + this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( + switchMap(([currentPagination, currentSort]) => { + return this.cds.findByParent(this.community.id, { + currentPage: currentPagination.currentPage, + elementsPerPage: currentPagination.pageSize, + sort: { field: currentSort.field, direction: currentSort.direction }, + }); + }), + ).subscribe((results) => { + this.subCollectionsRDObs.next(results); + })); + } + + ngOnDestroy(): void { + this.paginationService.clearPagination(this.config?.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts similarity index 56% rename from src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts index f1f49f204c2..4a965bc9264 100644 --- a/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts @@ -1,12 +1,18 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { + Component, + Input, +} from '@angular/core'; + +import { Community } from '../../../../core/shared/community.model'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -import { Component, Input } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; @Component({ - selector: 'ds-themed-community-page-sub-collection-list', + selector: 'ds-community-page-sub-collection-list', styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', + templateUrl: '../../../../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityPageSubCollectionListComponent], }) export class ThemedCollectionPageSubCollectionListComponent extends ThemedComponent { @Input() community: Community; @@ -18,7 +24,7 @@ export class ThemedCollectionPageSubCollectionListComponent extends ThemedCompon } protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/community-page/sub-collection-list/community-page-sub-collection-list.component`); + return import(`../../../../../themes/${themeName}/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component`); } protected importUnthemedComponent(): Promise { diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html new file mode 100644 index 00000000000..a811014bcc9 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss rename to src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.scss diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts new file mode 100644 index 00000000000..85d8eb4fb70 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts @@ -0,0 +1,35 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { SubComColSectionComponent } from './sub-com-col-section.component'; + +describe('SubComColSectionComponent', () => { + let component: SubComColSectionComponent; + let fixture: ComponentFixture; + + let activatedRoute: ActivatedRouteStub; + + beforeEach(async () => { + activatedRoute = new ActivatedRouteStub(); + activatedRoute.parent = new ActivatedRouteStub(); + + await TestBed.configureTestingModule({ + imports: [SubComColSectionComponent], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubComColSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts new file mode 100644 index 00000000000..7aed3be076b --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts @@ -0,0 +1,48 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Data, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; +import { ThemedCollectionPageSubCollectionListComponent } from './sub-collection-list/themed-community-page-sub-collection-list.component'; +import { ThemedCommunityPageSubCommunityListComponent } from './sub-community-list/themed-community-page-sub-community-list.component'; + +@Component({ + selector: 'ds-sub-com-col-section', + templateUrl: './sub-com-col-section.component.html', + styleUrls: ['./sub-com-col-section.component.scss'], + imports: [ + ThemedCommunityPageSubCommunityListComponent, + ThemedCollectionPageSubCollectionListComponent, + AsyncPipe, + NgIf, + ], + standalone: true, +}) +export class SubComColSectionComponent implements OnInit { + + community$: Observable; + + constructor( + private route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.community$ = this.route.parent.data.pipe( + map((data: Data) => (data.dso as RemoteData).payload), + ); + } + +} diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html similarity index 71% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html index be2788a9f40..7f9840f6b7f 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html @@ -1,6 +1,6 @@
-

{{'community.sub-community-list.head' | translate}}

+

{{'community.sub-community-list.head' | translate}}

{{'community.sub-community-list.head' | translate}}
- +
diff --git a/src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.scss b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.scss similarity index 100% rename from src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.scss rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.scss diff --git a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts new file mode 100644 index 00000000000..2654585eda9 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -0,0 +1,208 @@ +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { LinkHeadService } from '../../../../core/services/link-head.service'; +import { Community } from '../../../../core/shared/community.model'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { HostWindowService } from '../../../../shared/host-window.service'; +import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; +import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { ThemeService } from '../../../../shared/theme-support/theme.service'; +import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; + +describe('CommunityPageSubCommunityListComponent', () => { + let comp: CommunityPageSubCommunityListComponent; + let fixture: ComponentFixture; + let communityDataServiceStub: any; + let themeService; + let subCommList = []; + + const subcommunities = [Object.assign(new Community(), { + id: '123456789-1', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 1' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 2' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-3', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 3' }, + ], + }, + }), + Object.assign(new Community(), { + id: '12345678942', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 4' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-5', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 5' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-6', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 6' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-7', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 7' }, + ], + }, + }), + ]; + + const mockCommunity = Object.assign(new Community(), { + id: '123456789', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' }, + ], + }, + }); + + communityDataServiceStub = { + findByParent(parentUUID: string, options: FindListOptions = {}) { + let currentPage = options.currentPage; + let elementsPerPage = options.elementsPerPage; + if (currentPage === undefined) { + currentPage = 1; + } + elementsPerPage = 5; + + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > subCommList.length) { + endPageIndex = subCommList.length; + } + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCommList.slice(startPageIndex, endPageIndex))); + + }, + }; + + 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(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]), + NgbModule, + NoopAnimationsModule, + CommunityPageSubCommunityListComponent, + ], + providers: [ + { provide: CommunityDataService, useValue: communityDataServiceStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, + { 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(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent); + comp = fixture.componentInstance; + comp.community = mockCommunity; + + }); + + + it('should display a list of sub-communities', async () => { + subCommList = subcommunities; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const subComList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(subComList.length).toEqual(5); + expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); + expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); + expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); + expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); + }); + + it('should not display the header when list of sub-communities is empty', () => { + subCommList = []; + fixture.detectChanges(); + + const subComHead = fixture.debugElement.queryAll(By.css('h2')); + expect(subComHead.length).toEqual(0); + }); +}); diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts similarity index 55% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts index 5a0409a0519..36bd9919bb8 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts @@ -1,24 +1,58 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; - -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; - -import { RemoteData } from '../../core/data/remote-data'; -import { Community } from '../../core/shared/community.model'; -import { fadeIn } from '../../shared/animations/fade'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Subscription, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { hasValue } from '../../shared/empty.util'; + +import { + SortDirection, + SortOptions, +} from '../../../../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { Community } from '../../../../core/shared/community.model'; +import { fadeIn } from '../../../../shared/animations/fade'; +import { hasValue } from '../../../../shared/empty.util'; +import { ErrorComponent } from '../../../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../shared/object-collection/object-collection.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { VarDirective } from '../../../../shared/utils/var.directive'; @Component({ - selector: 'ds-community-page-sub-community-list', + selector: 'ds-base-community-page-sub-community-list', styleUrls: ['./community-page-sub-community-list.component.scss'], templateUrl: './community-page-sub-community-list.component.html', - animations: [fadeIn] + animations: [fadeIn], + imports: [ + ErrorComponent, + ThemedLoadingComponent, + VarDirective, + NgIf, + ObjectCollectionComponent, + AsyncPipe, + TranslateModule, + ObjectCollectionComponent, + ErrorComponent, + ThemedLoadingComponent, + VarDirective, + ], + standalone: true, }) /** * Component to render the sub-communities of a Community @@ -52,6 +86,8 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy */ subCommunitiesRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CommunityDataService, protected paginationService: PaginationService, @@ -79,21 +115,22 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); - observableCombineLatest([pagination$, sort$]).pipe( + this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( switchMap(([currentPagination, currentSort]) => { return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, elementsPerPage: currentPagination.pageSize, - sort: { field: currentSort.field, direction: currentSort.direction } + sort: { field: currentSort.field, direction: currentSort.direction }, }); - }) + }), ).subscribe((results) => { this.subCommunitiesRDObs.next(results); - }); + })); } ngOnDestroy(): void { - this.paginationService.clearPagination(this.config.id); + this.paginationService.clearPagination(this.config?.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts similarity index 56% rename from src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts index 852c53186ef..5988ad0f5ea 100644 --- a/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts @@ -1,12 +1,18 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { + Component, + Input, +} from '@angular/core'; + +import { Community } from '../../../../core/shared/community.model'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -import { Component, Input } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; @Component({ - selector: 'ds-themed-community-page-sub-community-list', + selector: 'ds-community-page-sub-community-list', styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', + templateUrl: '../../../../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityPageSubCommunityListComponent], }) export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponent { @@ -19,7 +25,7 @@ export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponen } protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/community-page/sub-community-list/community-page-sub-community-list.component`); + return import(`../../../../../themes/${themeName}/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component`); } protected importUnthemedComponent(): Promise { 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 deleted file mode 100644 index bca3c42a950..00000000000 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { RouterTestingModule } from '@angular/router/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -import { Community } from '../../core/shared/community.model'; -import { SharedModule } from '../../shared/shared.module'; -import { CollectionDataService } from '../../core/data/collection-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { buildPaginatedList } from '../../core/data/paginated-list.model'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { HostWindowService } from '../../shared/host-window.service'; -import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; -import { ThemeService } from '../../shared/theme-support/theme.service'; -import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; -import { GroupDataService } from '../../core/eperson/group-data.service'; -import { LinkHeadService } from '../../core/services/link-head.service'; -import { ConfigurationDataService } from '../../core/data/configuration-data.service'; -import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { 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; - let fixture: ComponentFixture; - let collectionDataServiceStub: any; - let themeService; - let subCollList = []; - - const collections = [Object.assign(new Community(), { - id: '123456789-1', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 1' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-2', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 2' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-3', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 3' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-4', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 4' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-5', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 5' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-6', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 6' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-7', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 7' } - ] - } - }) - ]; - - const mockCommunity = Object.assign(new Community(), { - id: '123456789', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Test title' } - ] - } - }); - - collectionDataServiceStub = { - findByParent(parentUUID: string, options: FindListOptions = {}) { - let currentPage = options.currentPage; - let elementsPerPage = options.elementsPerPage; - if (currentPage === undefined) { - currentPage = 1; - } - elementsPerPage = 5; - const startPageIndex = (currentPage - 1) * elementsPerPage; - let endPageIndex = (currentPage * elementsPerPage); - if (endPageIndex > subCollList.length) { - endPageIndex = subCollList.length; - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCollList.slice(startPageIndex, endPageIndex))); - - } - }; - - const paginationService = new PaginationServiceStub(); - - 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: [ - TranslateModule.forRoot(), - SharedModule, - RouterTestingModule.withRoutes([]), - NgbModule, - NoopAnimationsModule - ], - declarations: [CommunityPageSubCollectionListComponent], - providers: [ - { provide: CollectionDataService, useValue: collectionDataServiceStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { 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(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(CommunityPageSubCollectionListComponent); - comp = fixture.componentInstance; - comp.community = mockCommunity; - }); - - - 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', () => { - subCollList = []; - fixture.detectChanges(); - - const subComHead = fixture.debugElement.queryAll(By.css('h2')); - expect(subComHead.length).toEqual(0); - }); -}); 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 deleted file mode 100644 index 3a77149e5be..00000000000 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; - -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; - -import { RemoteData } from '../../core/data/remote-data'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; -import { fadeIn } from '../../shared/animations/fade'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CollectionDataService } from '../../core/data/collection-data.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { switchMap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; - -@Component({ - selector: 'ds-community-page-sub-collection-list', - styleUrls: ['./community-page-sub-collection-list.component.scss'], - templateUrl: './community-page-sub-collection-list.component.html', - animations:[fadeIn] -}) -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 - */ - config: PaginationComponentOptions; - - /** - * The pagination id - */ - pageId = 'cmcl'; - - /** - * The sorting configuration - */ - sortConfig: SortOptions; - - /** - * A list of remote data objects of communities' collections - */ - subCollectionsRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); - - constructor( - protected cds: CollectionDataService, - protected paginationService: PaginationService, - protected route: ActivatedRoute, - ) { - } - - ngOnInit(): void { - this.config = new PaginationComponentOptions(); - this.config.id = this.pageId; - if (hasValue(this.pageSize)) { - this.config.pageSize = this.pageSize; - } else { - this.config.pageSize = this.route.snapshot.queryParams[this.pageId + '.rpp'] ?? this.config.pageSize; - } - this.config.currentPage = this.route.snapshot.queryParams[this.pageId + '.page'] ?? 1; - this.sortConfig = new SortOptions('dc.title', SortDirection[this.route.snapshot.queryParams[this.pageId + '.sd']] ?? SortDirection.ASC); - this.initPage(); - } - - /** - * Initialise the list of collections - */ - initPage() { - const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); - const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); - - observableCombineLatest([pagination$, sort$]).pipe( - switchMap(([currentPagination, currentSort]) => { - return this.cds.findByParent(this.community.id, { - currentPage: currentPagination.currentPage, - elementsPerPage: currentPagination.pageSize, - sort: {field: currentSort.field, direction: currentSort.direction} - }); - }) - ).subscribe((results) => { - this.subCollectionsRDObs.next(results); - }); - } - - ngOnDestroy(): void { - this.paginationService.clearPagination(this.config.id); - } - -} 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 deleted file mode 100644 index 0a14fe6dd14..00000000000 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { By } from '@angular/platform-browser'; - -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -import { Community } from '../../core/shared/community.model'; -import { buildPaginatedList } from '../../core/data/paginated-list.model'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { SharedModule } from '../../shared/shared.module'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { HostWindowService } from '../../shared/host-window.service'; -import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; -import { ThemeService } from '../../shared/theme-support/theme.service'; -import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; -import { GroupDataService } from '../../core/eperson/group-data.service'; -import { LinkHeadService } from '../../core/services/link-head.service'; -import { ConfigurationDataService } from '../../core/data/configuration-data.service'; -import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; -import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; -import { createPaginatedList } from '../../shared/testing/utils.test'; - -describe('CommunityPageSubCommunityListComponent Component', () => { - let comp: CommunityPageSubCommunityListComponent; - let fixture: ComponentFixture; - let communityDataServiceStub: any; - let themeService; - let subCommList = []; - - const subcommunities = [Object.assign(new Community(), { - id: '123456789-1', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 1' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-2', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 2' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-3', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 3' } - ] - } - }), - Object.assign(new Community(), { - id: '12345678942', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 4' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-5', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 5' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-6', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 6' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-7', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 7' } - ] - } - }) - ]; - - const mockCommunity = Object.assign(new Community(), { - id: '123456789', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Test title' } - ] - } - }); - - communityDataServiceStub = { - findByParent(parentUUID: string, options: FindListOptions = {}) { - let currentPage = options.currentPage; - let elementsPerPage = options.elementsPerPage; - if (currentPage === undefined) { - currentPage = 1; - } - elementsPerPage = 5; - - const startPageIndex = (currentPage - 1) * elementsPerPage; - let endPageIndex = (currentPage * elementsPerPage); - if (endPageIndex > subCommList.length) { - endPageIndex = subCommList.length; - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCommList.slice(startPageIndex, endPageIndex))); - - } - }; - - 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(); - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - SharedModule, - RouterTestingModule.withRoutes([]), - NgbModule, - NoopAnimationsModule - ], - declarations: [CommunityPageSubCommunityListComponent], - providers: [ - { provide: CommunityDataService, useValue: communityDataServiceStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { 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(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent); - comp = fixture.componentInstance; - comp.community = mockCommunity; - - }); - - - 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', () => { - subCommList = []; - fixture.detectChanges(); - - const subComHead = fixture.debugElement.queryAll(By.css('h2')); - expect(subComHead.length).toEqual(0); - }); -}); diff --git a/src/app/community-page/themed-community-page.component.ts b/src/app/community-page/themed-community-page.component.ts index eeb058fb047..b655452041a 100644 --- a/src/app/community-page/themed-community-page.component.ts +++ b/src/app/community-page/themed-community-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CommunityPageComponent } from './community-page.component'; @@ -6,9 +7,11 @@ import { CommunityPageComponent } from './community-page.component'; * Themed wrapper for CommunityPageComponent */ @Component({ - selector: 'ds-themed-community-page', + selector: 'ds-community-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', + standalone: true, + imports: [CommunityPageComponent], }) export class ThemedCommunityPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts index 3747edd532c..295e5b1e751 100644 --- a/src/app/core/auth/auth-blocking.guard.spec.ts +++ b/src/app/core/auth/auth-blocking.guard.spec.ts @@ -1,15 +1,26 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { Store, StoreModule } from '@ngrx/store'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { cold } from 'jasmine-marbles'; -import { AppState, storeModuleConfig } from '../../app.reducer'; -import { AuthBlockingGuard } from './auth-blocking.guard'; +import { + AppState, + storeModuleConfig, +} from '../../app.reducer'; import { authReducer } from './auth.reducer'; +import { authBlockingGuard } from './auth-blocking.guard'; -describe('AuthBlockingGuard', () => { - let guard: AuthBlockingGuard; +describe('authBlockingGuard', () => { + let guard: any; let initialState; let store: Store; let mockStore: MockStore; @@ -21,9 +32,9 @@ describe('AuthBlockingGuard', () => { loaded: false, blocking: undefined, loading: false, - authMethods: [] - } - } + authMethods: [], + }, + }, }; beforeEach(waitForAsync(() => { @@ -33,22 +44,22 @@ describe('AuthBlockingGuard', () => { ], providers: [ provideMockStore({ initialState }), - { provide: AuthBlockingGuard, useValue: guard } - ] + { provide: authBlockingGuard, useValue: guard }, + ], }).compileComponents(); })); beforeEach(() => { store = TestBed.inject(Store); mockStore = store as MockStore; - guard = new AuthBlockingGuard(store); + guard = authBlockingGuard; }); describe(`canActivate`, () => { describe(`when authState.blocking is undefined`, () => { it(`should not emit anything`, (done) => { - expect(guard.canActivate()).toBeObservable(cold('-')); + expect(guard(null, null, store)).toBeObservable(cold('-')); done(); }); }); @@ -58,15 +69,15 @@ describe('AuthBlockingGuard', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'auth': { - blocking: true - } - }) + blocking: true, + }, + }), }); mockStore.setState(state); }); it(`should not emit anything`, (done) => { - expect(guard.canActivate()).toBeObservable(cold('-')); + expect(guard(null, null, store)).toBeObservable(cold('-')); done(); }); }); @@ -76,15 +87,15 @@ describe('AuthBlockingGuard', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'auth': { - blocking: false - } - }) + blocking: false, + }, + }), }); mockStore.setState(state); }); it(`should succeed`, (done) => { - expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true })); + expect(guard(null, null, store)).toBeObservable(cold('(a|)', { a: true })); done(); }); }); diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts index 9054f66f8b1..c76480ec0d2 100644 --- a/src/app/core/auth/auth-blocking.guard.ts +++ b/src/app/core/auth/auth-blocking.guard.ts @@ -1,8 +1,21 @@ -import { Injectable } from '@angular/core'; -import { CanActivate } from '@angular/router'; -import { select, Store } from '@ngrx/store'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + RouterStateSnapshot, +} from '@angular/router'; +import { + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, + take, +} from 'rxjs/operators'; + import { AppState } from '../../app.reducer'; import { isAuthenticationBlocking } from './selectors'; @@ -11,24 +24,16 @@ import { isAuthenticationBlocking } from './selectors'; * route until the authentication status has loaded. * To ensure all rest requests get the correct auth header. */ -@Injectable({ - providedIn: 'root' -}) -export class AuthBlockingGuard implements CanActivate { - - constructor(private store: Store) { - } - - /** - * True when the authentication isn't blocking everything - */ - canActivate(): Observable { - return this.store.pipe(select(isAuthenticationBlocking)).pipe( - map((isBlocking: boolean) => isBlocking === false), - distinctUntilChanged(), - filter((finished: boolean) => finished === true), - take(1), - ); - } +export const authBlockingGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + store: Store = inject(Store), +): Observable => { + return store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged(), + filter((finished: boolean) => finished === true), + take(1), + ); +}; -} diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts index 063aad612f1..2220efe5faa 100644 --- a/src/app/core/auth/auth-request.service.spec.ts +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -1,17 +1,21 @@ -import { AuthRequestService } from './auth-request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { PostRequest } from '../data/request.models'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { ShortLivedToken } from './models/short-lived-token.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequestMethod } from '../data/rest-request-method'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import objectContaining = jasmine.objectContaining; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthRequestService } from './auth-request.service'; import { AuthStatus } from './models/auth-status.model'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { Observable, of as observableOf } from 'rxjs'; +import { ShortLivedToken } from './models/short-lived-token.model'; +import objectContaining = jasmine.objectContaining; describe(`AuthRequestService`, () => { let halService: HALEndpointService; @@ -30,7 +34,7 @@ describe(`AuthRequestService`, () => { constructor( hes: HALEndpointService, rs: RequestService, - rdbs: RemoteDataBuildService + rdbs: RemoteDataBuildService, ) { super(hes, rs, rdbs); } @@ -44,19 +48,19 @@ describe(`AuthRequestService`, () => { endpointURL = 'https://rest.api/auth'; requestID = 'requestID'; shortLivedToken = Object.assign(new ShortLivedToken(), { - value: 'some-token' + value: 'some-token', }); shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); halService = jasmine.createSpyObj('halService', { - 'getEndpoint': cold('a', { a: endpointURL }) + 'getEndpoint': cold('a', { a: endpointURL }), }); requestService = jasmine.createSpyObj('requestService', { 'generateRequestId': requestID, 'send': null, }); rdbService = jasmine.createSpyObj('rdbService', { - 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) + 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }), }); service = new TestAuthRequestService(halService, requestService, rdbService); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 7c1f17dec23..5d11b9f4cb0 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,18 +1,29 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, tap, take } from 'rxjs/operators'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + import { isNotEmpty } from '../../shared/empty.util'; -import { GetRequest, PostRequest, } from '../data/request.models'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; +import { + GetRequest, + PostRequest, +} from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequest } from '../data/rest-request.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { RestRequest } from '../data/rest-request.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * Abstract service to send authentication requests @@ -23,8 +34,8 @@ export abstract class AuthRequestService { constructor(protected halService: HALEndpointService, protected requestService: RequestService, - private rdbService: RemoteDataBuildService - ) { + private rdbService: RemoteDataBuildService, + ) { } /** @@ -65,7 +76,7 @@ export abstract class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)), - take(1) + take(1), ).subscribe((request: PostRequest) => { this.requestService.send(request); }); @@ -90,7 +101,7 @@ export abstract class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), distinctUntilChanged(), map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)), - take(1) + take(1), ).subscribe((request: GetRequest) => { this.requestService.send(request); }); @@ -125,7 +136,7 @@ export abstract class AuthRequestService { } else { return null; } - }) + }), ); } } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 6bc4565682a..03b6bc11910 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,12 +1,13 @@ /* eslint-disable max-classes-per-file */ // import @ngrx import { Action } from '@ngrx/store'; + // import type function import { type } from '../../shared/ngrx/type'; -// import models -import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; +// import models +import { AuthTokenInfo } from './models/auth-token-info.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -38,7 +39,7 @@ export const AuthActionTypes = { RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS'), SET_USER_AS_IDLE: type('dspace/auth/SET_USER_AS_IDLE'), - UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE') + UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE'), }; @@ -426,6 +427,15 @@ export class UnsetUserAsIdleAction implements Action { public type: string = AuthActionTypes.UNSET_USER_AS_IDLE; } +/** + * Authentication error actions that include Error payloads. + */ +export type AuthErrorActionsWithErrorPayload + = AuthenticatedErrorAction + | AuthenticationErrorAction + | LogOutErrorAction + | RetrieveAuthenticatedEpersonErrorAction; + /** * Actions type. * @type {AuthActions} @@ -433,9 +443,7 @@ export class UnsetUserAsIdleAction implements Action { export type AuthActions = AuthenticateAction | AuthenticatedAction - | AuthenticatedErrorAction | AuthenticatedSuccessAction - | AuthenticationErrorAction | AuthenticationSuccessAction | CheckAuthenticationTokenAction | CheckAuthenticationTokenCookieAction @@ -452,10 +460,9 @@ export type AuthActions | RetrieveAuthMethodsErrorAction | RetrieveTokenAction | RetrieveAuthenticatedEpersonAction - | RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonSuccessAction | SetRedirectUrlAction | RedirectAfterLoginSuccessAction | SetUserAsIdleAction - | UnsetUserAsIdleAction; - + | UnsetUserAsIdleAction + | AuthErrorActionsWithErrorPayload; diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 2e6ba917aae..a423455594a 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -1,12 +1,38 @@ -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; - +import { + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, + throwError as observableThrow, +} from 'rxjs'; -import { AuthEffects } from './auth.effects'; +import { + AppState, + storeModuleConfig, +} from '../../app.reducer'; +import { + authMethodsMock, + AuthServiceStub, +} from '../../shared/testing/auth-service.stub'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { StoreActionTypes } from '../../store.actions'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { AuthActionTypes, AuthenticatedAction, @@ -25,17 +51,16 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, - RetrieveTokenAction + RetrieveTokenAction, } from './auth.actions'; -import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub'; -import { AuthService } from './auth.service'; +import { AuthEffects } from './auth.effects'; import { authReducer } from './auth.reducer'; +import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AppState, storeModuleConfig } from '../../app.reducer'; -import { StoreActionTypes } from '../../store.actions'; -import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { + isAuthenticated, + isAuthenticatedLoaded, +} from './selectors'; describe('AuthEffects', () => { let authEffects: AuthEffects; @@ -56,9 +81,9 @@ describe('AuthEffects', () => { authenticated: false, loaded: false, loading: false, - authMethods: [] - } - } + authMethods: [], + }, + }, }; } @@ -66,7 +91,7 @@ describe('AuthEffects', () => { init(); TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({ auth: authReducer }, storeModuleConfig) + StoreModule.forRoot({ auth: authReducer }, storeModuleConfig), ], providers: [ AuthEffects, @@ -88,8 +113,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE, - payload: { email: 'user', password: 'password' } - } + payload: { email: 'user', password: 'password' }, + }, }); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); @@ -105,8 +130,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE, - payload: { email: 'user', password: 'wrongpassword' } - } + payload: { email: 'user', password: 'wrongpassword' }, + }, }); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); @@ -161,9 +186,9 @@ describe('AuthEffects', () => { type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: { authenticated: true, authToken: token, - userHref: EPersonMock._links.self.href - } - } + userHref: EPersonMock._links.self.href, + }, + }, }); const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) }); @@ -211,8 +236,8 @@ describe('AuthEffects', () => { spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( observableOf( { - authenticated: true - }) + authenticated: true, + }), ); spyOn((authEffects as any).authService, 'setExternalAuthStatus'); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); @@ -230,7 +255,7 @@ describe('AuthEffects', () => { it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( observableOf( - { authenticated: false }) + { authenticated: false }), ); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); @@ -260,8 +285,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON, - payload: EPersonMock._links.self.href - } + payload: EPersonMock._links.self.href, + }, }); const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id) }); @@ -314,8 +339,8 @@ describe('AuthEffects', () => { it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => { actions = hot('--a-', { a: { - type: AuthActionTypes.RETRIEVE_TOKEN - } + type: AuthActionTypes.RETRIEVE_TOKEN, + }, }); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); @@ -330,8 +355,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { - type: AuthActionTypes.RETRIEVE_TOKEN - } + type: AuthActionTypes.RETRIEVE_TOKEN, + }, }); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 281355b769e..2919a40fa85 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,27 +1,47 @@ -import { Injectable, NgZone } from '@angular/core'; - +import { + Injectable, + NgZone, + Type, +} from '@angular/core'; +// import @ngrx +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + select, + Store, +} from '@ngrx/store'; import { asyncScheduler, combineLatest as observableCombineLatest, Observable, of as observableOf, queueScheduler, - timer + timer, } from 'rxjs'; -import { catchError, filter, map, observeOn, switchMap, take, tap } from 'rxjs/operators'; -// import @ngrx -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Action, select, Store } from '@ngrx/store'; +import { + catchError, + filter, + map, + observeOn, + switchMap, + take, + tap, +} from 'rxjs/operators'; -// import services -import { AuthService } from './auth.service'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; +import { environment } from '../../../environments/environment'; import { AppState } from '../../app.reducer'; -import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; import { StoreActionTypes } from '../../store.actions'; -import { AuthMethod } from './models/auth.method'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { RequestActionTypes } from '../data/request.actions'; +import { EPerson } from '../eperson/models/eperson.model'; +import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; +import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; // import actions import { AuthActionTypes, @@ -31,6 +51,7 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, + AuthErrorActionsWithErrorPayload, CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, @@ -45,20 +66,32 @@ import { RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, RetrieveTokenAction, - SetUserAsIdleAction + SetUserAsIdleAction, } from './auth.actions'; -import { hasValue } from '../../shared/empty.util'; -import { environment } from '../../../environments/environment'; -import { RequestActionTypes } from '../data/request.actions'; -import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; -import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; -import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +// import services +import { AuthService } from './auth.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { + isAuthenticated, + isAuthenticatedLoaded, +} from './selectors'; // Action Types that do not break/prevent the user from an idle state const IDLE_TIMER_IGNORE_TYPES: string[] = [...Object.values(AuthActionTypes).filter((t: string) => t !== AuthActionTypes.UNSET_USER_AS_IDLE), - ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; + ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; + +export function errorToAuthAction$(actionType: Type, error: unknown): Observable { + if (error instanceof Error) { + return observableOf(new actionType(error)); + } + + // If we caught something that's not an Error: complain & drop type safety + console.warn('AuthEffects caught non-Error object:', error); + return observableOf(new actionType(error as any)); +} @Injectable() export class AuthEffects { @@ -73,14 +106,14 @@ export class AuthEffects { return this.authService.authenticate(action.payload.email, action.payload.password).pipe( take(1), map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticationErrorAction, error)), ); - }) + }), )); public authenticateSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)), )); public authenticated$: Observable = createEffect(() => this.actions$.pipe( @@ -88,8 +121,9 @@ export class AuthEffects { switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); - }) + catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), + ); + }), )); public authenticatedSuccess$: Observable = createEffect(() => this.actions$.pipe( @@ -97,7 +131,7 @@ export class AuthEffects { tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( take(1), - map((redirectUrl: string) => [action, redirectUrl]) + map((redirectUrl: string) => [action, redirectUrl]), )), map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => { if (hasValue(redirectUrl)) { @@ -105,7 +139,7 @@ export class AuthEffects { } else { return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); } - }) + }), )); public redirectAfterLoginSuccess$: Observable = createEffect(() => this.actions$.pipe( @@ -113,13 +147,13 @@ export class AuthEffects { tap((action: RedirectAfterLoginSuccessAction) => { this.authService.clearRedirectUrl(); this.authService.navigateToRedirectUrl(action.payload); - }) + }), ), { dispatch: false }); // It means "reacts to this action but don't send another" public authenticatedError$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_ERROR), - tap((action: LogOutSuccessAction) => this.authService.removeToken()) + tap((action: LogOutSuccessAction) => this.authService.removeToken()), ), { dispatch: false }); public retrieveAuthenticatedEperson$: Observable = createEffect(() => this.actions$.pipe( @@ -134,17 +168,18 @@ export class AuthEffects { } return user$.pipe( map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), - catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); - }) + catchError((error: unknown) => errorToAuthAction$(RetrieveAuthenticatedEpersonErrorAction, error)), + ); + }), )); public checkToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), switchMap(() => { return this.authService.hasValidAuthenticationToken().pipe( map((token: AuthTokenInfo) => new AuthenticatedAction(token)), - catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) + catchError((error: unknown) => observableOf(new CheckAuthenticationTokenCookieAction())), ); - }) + }), )); public checkTokenCookie$: Observable = createEffect(() => this.actions$.pipe( @@ -160,9 +195,9 @@ export class AuthEffects { return new RetrieveAuthMethodsAction(response); } }), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), ); - }) + }), )); public retrieveToken$: Observable = createEffect(() => this.actions$.pipe( @@ -171,24 +206,24 @@ export class AuthEffects { return this.authService.refreshAuthenticationToken(null).pipe( take(1), map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticationErrorAction, error)), ); - }) + }), )); public refreshToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), switchMap((action: RefreshTokenAction) => { return this.authService.refreshAuthenticationToken(action.payload).pipe( map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), - catchError((error) => observableOf(new RefreshTokenErrorAction())) + catchError((error: unknown) => observableOf(new RefreshTokenErrorAction())), ); - }) + }), )); // It means "reacts to this action but don't send another" public refreshTokenSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), - tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)), ), { dispatch: false }); /** @@ -204,7 +239,7 @@ export class AuthEffects { take(1), filter(([loaded, authenticated]) => loaded && !authenticated), tap(() => this.authService.removeToken()), - tap(() => this.authService.resetAuthenticationError()) + tap(() => this.authService.resetAuthenticationError()), ); })), { dispatch: false }); @@ -213,9 +248,9 @@ export class AuthEffects { * authorizations endpoint, to be sure to have consistent responses after a login with external idp * */ - invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ + invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), - tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()) + tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()), ), { dispatch: false }); public logOut$: Observable = createEffect(() => this.actions$ @@ -224,24 +259,24 @@ export class AuthEffects { switchMap(() => { this.authService.stopImpersonating(); return this.authService.logout().pipe( - map((value) => new LogOutSuccessAction()), - catchError((error) => observableOf(new LogOutErrorAction(error))) + map(() => new LogOutSuccessAction()), + catchError((error: unknown) => errorToAuthAction$(LogOutErrorAction, error)), ); - }) + }), )); public logOutSuccess$: Observable = createEffect(() => this.actions$ .pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS), tap(() => this.authService.removeToken()), tap(() => this.authService.clearRedirectUrl()), - tap(() => this.authService.refreshAfterLogout()) + tap(() => this.authService.refreshAfterLogout()), ), { dispatch: false }); public redirectToLoginTokenExpired$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED), tap(() => this.authService.removeToken()), - tap(() => this.authService.redirectToLoginWhenTokenExpired()) + tap(() => this.authService.redirectToLoginWhenTokenExpired()), ), { dispatch: false }); public retrieveMethods$: Observable = createEffect(() => this.actions$ @@ -251,9 +286,9 @@ export class AuthEffects { return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) .pipe( map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), - catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + catchError(() => observableOf(new RetrieveAuthMethodsErrorAction())), ); - }) + }), )); /** @@ -268,7 +303,7 @@ export class AuthEffects { // in, and start a new timer switchMap(() => // Start a timer outside of Angular's zone - timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)) + timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)), ), // Re-enter the zone to dispatch the action observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 04bbc4acaf0..d824df472a2 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -1,18 +1,20 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; - import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; -import { AuthInterceptor } from './auth.interceptor'; -import { AuthService } from './auth.service'; -import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RouterStub } from '../../shared/testing/router.stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; -import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RestRequestMethod } from '../data/rest-request-method'; +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { AuthInterceptor } from './auth.interceptor'; +import { AuthService } from './auth.service'; describe(`AuthInterceptor`, () => { let service: DspaceRestService; @@ -20,10 +22,8 @@ describe(`AuthInterceptor`, () => { const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - select: observableOf(true) + select: observableOf(true), }); beforeEach(() => { @@ -46,6 +46,10 @@ describe(`AuthInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); }); + afterEach(() => { + httpMock.verify(); + }); + describe('when has a valid token', () => { it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint that is not the logout endpoint', () => { @@ -95,14 +99,11 @@ describe(`AuthInterceptor`, () => { }); it('should redirect to login', () => { - - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { - expect(response).toBeTruthy(); - }); - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); + // HttpTestingController.expectNone will throw an error when a requests is made + expect().nothing(); }); }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 4ad856fd882..d011d27059a 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,7 +1,3 @@ -import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; - -import { catchError, map } from 'rxjs/operators'; -import { Injectable, Injector } from '@angular/core'; import { HttpErrorResponse, HttpEvent, @@ -10,19 +6,36 @@ import { HttpInterceptor, HttpRequest, HttpResponse, - HttpResponseBase + HttpResponseBase, } from '@angular/common/http'; +import { + Injectable, + Injector, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { + Observable, + of as observableOf, + throwError as observableThrowError, +} from 'rxjs'; +import { + catchError, + map, +} from 'rxjs/operators'; import { AppState } from '../../app.reducer'; -import { AuthService } from './auth.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, + isNotNull, +} from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction } from './auth.actions'; -import { Store } from '@ngrx/store'; -import { Router } from '@angular/router'; +import { AuthService } from './auth.service'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -116,12 +129,24 @@ export class AuthInterceptor implements HttpInterceptor { */ private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] { const sortedAuthMethodModels: AuthMethod[] = []; + let passwordAuthFound = false; + let ldapAuthFound = false; + authMethodModels.forEach((method) => { if (method.authMethodType === AuthMethodType.Password) { sortedAuthMethodModels.push(method); + passwordAuthFound = true; + } + if (method.authMethodType === AuthMethodType.Ldap) { + ldapAuthFound = true; } }); + // Using password authentication method to provide UI for LDAP authentication even if password auth is not present in server + if (ldapAuthFound && !(passwordAuthFound)) { + sortedAuthMethodModels.push(new AuthMethod(AuthMethodType.Password,0)); + } + authMethodModels.forEach((method) => { if (method.authMethodType !== AuthMethodType.Password) { sortedAuthMethodModels.push(method); @@ -207,8 +232,8 @@ export class AuthInterceptor implements HttpInterceptor { message: 'Unknown auth error', status: 500, timestamp: Date.now(), - path: '' - }; + path: '', + }; } } else { authStatus.error = error; @@ -261,7 +286,7 @@ export class AuthInterceptor implements HttpInterceptor { // login successfully const newToken = response.headers.get('authorization'); authRes = response.clone({ - body: this.makeAuthStatusObject(true, newToken) + body: this.makeAuthStatusObject(true, newToken), }); // clean eventually refresh Requests list @@ -269,13 +294,13 @@ export class AuthInterceptor implements HttpInterceptor { } else if (this.isStatusResponse(response)) { authRes = response.clone({ body: Object.assign(response.body, { - authMethods: this.parseAuthMethodsFromHeaders(response.headers) - }) + authMethods: this.parseAuthMethodsFromHeaders(response.headers), + }), }); } else { // logout successfully authRes = response.clone({ - body: this.makeAuthStatusObject(false) + body: this.makeAuthStatusObject(false), }); } return authRes; @@ -283,7 +308,7 @@ export class AuthInterceptor implements HttpInterceptor { return response; } }), - catchError((error, caught) => { + catchError((error: unknown, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { @@ -298,7 +323,7 @@ export class AuthInterceptor implements HttpInterceptor { headers: error.headers, status: error.status, statusText: error.statusText, - url: error.url + url: error.url, }); return observableOf(authResponse); } else if (this.isUnauthorized(error) && isNotNull(token) && authService.isTokenExpired()) { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 41c03126538..7860744aa5b 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -1,4 +1,4 @@ -import { authReducer, AuthState } from './auth.reducer'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; import { AddAuthenticationMessageAction, AuthenticateAction, @@ -8,7 +8,6 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, - SetAuthCookieStatus, CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, @@ -24,15 +23,19 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, + SetAuthCookieStatus, SetRedirectUrlAction, SetUserAsIdleAction, - UnsetUserAsIdleAction + UnsetUserAsIdleAction, } from './auth.actions'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AuthStatus } from './models/auth-status.model'; +import { + authReducer, + AuthState, +} from './auth.reducer'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; describe('authReducer', () => { @@ -47,7 +50,7 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: false, - idle: false + idle: false, }; const action = new AuthenticateAction('user', 'password'); const newState = authReducer(initialState, action); @@ -58,7 +61,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); @@ -72,7 +75,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticationSuccessAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -88,7 +91,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticationErrorAction(mockError); const newState = authReducer(initialState, action); @@ -100,7 +103,7 @@ describe('authReducer', () => { info: undefined, authToken: undefined, error: 'Test error message', - idle: false + idle: false, }; expect(newState).toEqual(state); @@ -114,7 +117,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -125,7 +128,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -138,7 +141,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href); const newState = authReducer(initialState, action); @@ -150,7 +153,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -163,7 +166,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedErrorAction(mockError); const newState = authReducer(initialState, action); @@ -175,7 +178,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -186,7 +189,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new CheckAuthenticationTokenAction(); const newState = authReducer(initialState, action); @@ -195,7 +198,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -206,7 +209,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); @@ -215,7 +218,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -227,7 +230,7 @@ describe('authReducer', () => { blocking: false, loading: true, externalAuth: false, - idle: false + idle: false, }; const action = new SetAuthCookieStatus(true); const newState = authReducer(initialState, action); @@ -237,7 +240,7 @@ describe('authReducer', () => { blocking: false, loading: true, externalAuth: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -252,7 +255,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutAction(); @@ -271,7 +274,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutSuccessAction(); @@ -286,7 +289,7 @@ describe('authReducer', () => { info: undefined, refreshing: false, userId: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -301,7 +304,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutErrorAction(mockError); @@ -315,7 +318,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -329,7 +332,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const newState = authReducer(initialState, action); @@ -342,7 +345,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -355,7 +358,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new RetrieveAuthenticatedEpersonErrorAction(mockError); const newState = authReducer(initialState, action); @@ -367,7 +370,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -382,7 +385,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -397,7 +400,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -413,7 +416,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenSuccessAction(newTokenInfo); @@ -428,7 +431,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: false, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -444,7 +447,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; const action = new RefreshTokenErrorAction(); const newState = authReducer(initialState, action); @@ -458,7 +461,7 @@ describe('authReducer', () => { info: undefined, refreshing: false, userId: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -473,7 +476,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; state = { @@ -485,7 +488,7 @@ describe('authReducer', () => { error: undefined, info: 'Message', userId: undefined, - idle: false + idle: false, }; }); @@ -507,7 +510,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new AddAuthenticationMessageAction('Message'); const newState = authReducer(initialState, action); @@ -517,7 +520,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: 'Message', - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -530,7 +533,7 @@ describe('authReducer', () => { loading: false, error: 'Error', info: 'Message', - idle: false + idle: false, }; const action = new ResetAuthenticationMessagesAction(); const newState = authReducer(initialState, action); @@ -541,7 +544,7 @@ describe('authReducer', () => { loading: false, error: undefined, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -552,7 +555,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new SetRedirectUrlAction('redirect.url'); const newState = authReducer(initialState, action); @@ -562,7 +565,7 @@ describe('authReducer', () => { blocking: false, loading: false, redirectUrl: 'redirect.url', - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -574,7 +577,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: [], - idle: false + idle: false, }; const action = new RetrieveAuthMethodsAction(new AuthStatus()); const newState = authReducer(initialState, action); @@ -584,7 +587,7 @@ describe('authReducer', () => { blocking: false, loading: true, authMethods: [], - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -596,7 +599,7 @@ describe('authReducer', () => { blocking: true, loading: true, authMethods: [], - idle: false + idle: false, }; const authMethods: AuthMethod[] = [ new AuthMethod(AuthMethodType.Password, 0), @@ -610,7 +613,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: authMethods, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -622,7 +625,7 @@ describe('authReducer', () => { blocking: true, loading: true, authMethods: [], - idle: false + idle: false, }; const action = new RetrieveAuthMethodsErrorAction(); @@ -633,7 +636,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: [new AuthMethod(AuthMethodType.Password, 0)], - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -644,7 +647,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: false + idle: false, }; const action = new SetUserAsIdleAction(); @@ -654,7 +657,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: true + idle: true, }; expect(newState).toEqual(state); }); @@ -665,7 +668,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: true + idle: true, }; const action = new UnsetUserAsIdleAction(); @@ -675,7 +678,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: false + idle: false, }; expect(newState).toEqual(state); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 437c19fd26e..8a399710eae 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -1,4 +1,5 @@ // import actions +import { StoreActionTypes } from '../../store.actions'; import { AddAuthenticationMessageAction, AuthActions, @@ -10,14 +11,14 @@ import { RedirectWhenTokenExpiredAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, - RetrieveAuthMethodsSuccessAction, SetAuthCookieStatus, - SetRedirectUrlAction + RetrieveAuthMethodsSuccessAction, + SetAuthCookieStatus, + SetRedirectUrlAction, } from './auth.actions'; -// import models -import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; -import { StoreActionTypes } from '../../store.actions'; +// import models +import { AuthTokenInfo } from './models/auth-token-info.model'; /** * The auth state. @@ -76,7 +77,7 @@ const initialState: AuthState = { loading: false, authMethods: [], externalAuth: false, - idle: false + idle: false, }; /** @@ -92,13 +93,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { error: undefined, loading: true, - info: undefined + info: undefined, }); case AuthActionTypes.AUTHENTICATED: return Object.assign({}, state, { loading: true, - blocking: true + blocking: true, }); case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: @@ -109,7 +110,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.SET_AUTH_COOKIE_STATUS: return Object.assign({}, state, { - externalAuth: (action as SetAuthCookieStatus).payload + externalAuth: (action as SetAuthCookieStatus).payload, }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -120,13 +121,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: (action as AuthenticationErrorAction).payload.message, loaded: true, blocking: false, - loading: false + loading: false, }); case AuthActionTypes.AUTHENTICATED_SUCCESS: return Object.assign({}, state, { authenticated: true, - authToken: (action as AuthenticatedSuccessAction).payload.authToken + authToken: (action as AuthenticatedSuccessAction).payload.authToken, }); case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: @@ -136,7 +137,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, blocking: false, info: undefined, - userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload + userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload, }); case AuthActionTypes.AUTHENTICATE_ERROR: @@ -145,7 +146,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, blocking: false, - loading: false + loading: false, }); case AuthActionTypes.AUTHENTICATE_SUCCESS: @@ -155,7 +156,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.LOG_OUT_ERROR: return Object.assign({}, state, { authenticated: true, - error: (action as LogOutErrorAction).payload.message + error: (action as LogOutErrorAction).payload.message, }); case AuthActionTypes.REFRESH_TOKEN_ERROR: @@ -168,7 +169,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, info: undefined, refreshing: false, - userId: undefined + userId: undefined, }); case AuthActionTypes.LOG_OUT_SUCCESS: @@ -181,7 +182,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: true, info: undefined, refreshing: false, - userId: undefined + userId: undefined, }); case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: @@ -193,7 +194,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut blocking: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, - userId: undefined + userId: undefined, }); case AuthActionTypes.REFRESH_TOKEN: @@ -205,7 +206,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authToken: (action as RefreshTokenSuccessAction).payload, refreshing: false, - blocking: false + blocking: false, }); case AuthActionTypes.ADD_MESSAGE: @@ -229,14 +230,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { loading: false, blocking: false, - authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload, }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: return Object.assign({}, state, { loading: false, blocking: false, - authMethods: [new AuthMethod(AuthMethodType.Password, 0)] + authMethods: [new AuthMethod(AuthMethodType.Password, 0)], }); case AuthActionTypes.SET_REDIRECT_URL: diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index b38d17aecdb..e596650cfad 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -1,46 +1,75 @@ -import { inject, TestBed, waitForAsync } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Store, StoreModule } from '@ngrx/store'; -import { REQUEST } from '@nguniversal/express-engine/tokens'; -import { Observable, of as observableOf } from 'rxjs'; -import { authReducer, AuthState } from './auth.reducer'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; -import { RouterStub } from '../../shared/testing/router.stub'; +import { + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { REQUEST } from '../../../express.tokens'; +import { AppState } from '../../app.reducer'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { CookieService } from '../services/cookie.service'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; -import { AuthRequestService } from './auth-request.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPerson } from '../eperson/models/eperson.model'; +import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AppState } from '../../app.reducer'; -import { ClientCookieService } from '../services/client-cookie.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; -import { RouteService } from '../services/route.service'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { + SpecialGroupDataMock, + SpecialGroupDataMock$, +} from '../../shared/testing/special-group.mock'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { authMethodsMock } from '../../shared/testing/auth-service.stub'; -import { AuthMethod } from './models/auth.method'; +import { EPerson } from '../eperson/models/eperson.model'; +import { ClientCookieService } from '../services/client-cookie.service'; +import { CookieService } from '../services/cookie.service'; import { HardRedirectService } from '../services/hard-redirect.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; -import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock'; -import { cold } from 'jasmine-marbles'; +import { RouteService } from '../services/route.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + SetUserAsIdleAction, + UnsetUserAsIdleAction, +} from './auth.actions'; +import { + authReducer, + AuthState, +} from './auth.reducer'; +import { + AuthService, + IMPERSONATING_COOKIE, +} from './auth.service'; +import { AuthRequestService } from './auth-request.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; describe('AuthService test', () => { const mockEpersonDataService: any = { findByHref(href: string): Observable> { return createSuccessfulRemoteDataObject$(EPersonMock); - } + }, }; let mockStore: Store; @@ -62,13 +91,13 @@ describe('AuthService test', () => { uuid: 'test', authenticated: true, okay: true, - specialGroups: SpecialGroupDataMock$ + specialGroups: SpecialGroupDataMock$, }); function init() { mockStore = jasmine.createSpyObj('store', { dispatch: {}, - pipe: observableOf(true) + pipe: observableOf(true), }); window = new NativeWindowRef(); routerStub = new RouterStub(); @@ -80,7 +109,7 @@ describe('AuthService test', () => { loading: false, authToken: token, user: EPersonMock, - idle: false + idle: false, }; unAuthenticatedState = { authenticated: false, @@ -88,7 +117,7 @@ describe('AuthService test', () => { loading: false, authToken: undefined, user: undefined, - idle: false + idle: false, }; idleState = { authenticated: true, @@ -96,12 +125,12 @@ describe('AuthService test', () => { loading: false, authToken: token, user: EPersonMock, - idle: true + idle: true, }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); linkService = { - resolveLinks: {} + resolveLinks: {}, }; hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); @@ -117,11 +146,10 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } + strictActionImmutability: false, + }, }), ], - declarations: [], providers: [ { provide: AuthRequestService, useValue: authRequest }, { provide: NativeWindowService, useValue: window }, @@ -135,7 +163,7 @@ describe('AuthService test', () => { { provide: NotificationsService, useValue: NotificationsServiceStub }, { provide: TranslateService, useValue: getMockTranslateService() }, CookieService, - AuthService + AuthService, ], }); authService = TestBed.inject(AuthService); @@ -238,9 +266,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -249,8 +277,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -260,7 +288,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('should return true when user is logged in', () => { @@ -313,9 +341,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -325,8 +353,8 @@ describe('AuthService test', () => { { provide: RemoteDataBuildService, useValue: linkService }, ClientCookieService, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -338,14 +366,14 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: expiredToken, - user: EPersonMock + user: EPersonMock, }; store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); storage = (authService as any).storage; routeServiceMock = TestBed.inject(RouteService); routerStub = TestBed.inject(Router); @@ -528,7 +556,7 @@ describe('AuthService test', () => { it('should call navigateToRedirectUrl with no url', () => { const expectRes = cold('(a|)', { - a: SpecialGroupDataMock + a: SpecialGroupDataMock, }); expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes); }); @@ -543,9 +571,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -554,8 +582,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -565,7 +593,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('should return null for the shortlived token', () => { @@ -583,9 +611,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -594,8 +622,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -605,7 +633,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = idleState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('isUserIdle should return true when user is not idle', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 6604936cde1..5d6e8d79070 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,18 +1,29 @@ -import { Inject, Injectable, Optional } from '@angular/core'; -import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; - -import { Observable, of as observableOf } from 'rxjs'; -import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; -import { select, Store } from '@ngrx/store'; +import { + Inject, + Injectable, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + select, + Store, +} from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { CookieAttributes } from 'js-cookie'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + startWith, + switchMap, + take, +} from 'rxjs/operators'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthRequestService } from './auth-request.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; +import { environment } from '../../../environments/environment'; +import { AppState } from '../../app.reducer'; import { hasNoValue, hasValue, @@ -20,42 +31,59 @@ import { isEmpty, isNotEmpty, isNotNull, - isNotUndefined + isNotUndefined, } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { EPerson } from '../eperson/models/eperson.model'; +import { Group } from '../eperson/models/group.model'; import { CookieService } from '../services/cookie.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { RouteService } from '../services/route.service'; import { - getAuthenticatedUserId, - getAuthenticationToken, getExternalAuthCookieStatus, - getRedirectUrl, - isAuthenticated, - isAuthenticatedLoaded, - isIdle, - isTokenRefreshing -} from './selectors'; -import { AppState } from '../../app.reducer'; + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../shared/operators'; +import { PageInfo } from '../shared/page-info.model'; import { CheckAuthenticationTokenAction, RefreshTokenAction, - ResetAuthenticationMessagesAction, SetAuthCookieStatus, + ResetAuthenticationMessagesAction, + SetAuthCookieStatus, SetRedirectUrlAction, SetUserAsIdleAction, - UnsetUserAsIdleAction + UnsetUserAsIdleAction, } from './auth.actions'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { RouteService } from '../services/route.service'; -import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; +import { AuthRequestService } from './auth-request.service'; 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'; +import { AuthStatus } from './models/auth-status.model'; +import { + AuthTokenInfo, + TOKENITEM, +} from './models/auth-token-info.model'; +import { + getAuthenticatedUserId, + getAuthenticationToken, + getExternalAuthCookieStatus, + getRedirectUrl, + isAuthenticated, + isAuthenticatedLoaded, + isIdle, + isTokenRefreshing, +} from './selectors'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -65,7 +93,7 @@ export const IMPERSONATING_COOKIE = 'dsImpersonatingEPerson'; /** * The auth service. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class AuthService { /** @@ -79,24 +107,23 @@ export class AuthService { */ private tokenRefreshTimer; - constructor(@Inject(REQUEST) protected req: any, - @Inject(NativeWindowService) protected _window: NativeWindowRef, - @Optional() @Inject(RESPONSE) private response: any, - protected authRequestService: AuthRequestService, - protected epersonService: EPersonDataService, - protected router: Router, - protected routeService: RouteService, - protected storage: CookieService, - protected store: Store, - protected hardRedirectService: HardRedirectService, - private notificationService: NotificationsService, - private translateService: TranslateService + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected authRequestService: AuthRequestService, + protected epersonService: EPersonDataService, + protected router: Router, + protected routeService: RouteService, + protected storage: CookieService, + protected store: Store, + protected hardRedirectService: HardRedirectService, + protected notificationService: NotificationsService, + protected translateService: TranslateService, ) { this.store.pipe( // when this service is constructed the store is not fully initialized yet filter((state: any) => state?.core?.auth !== undefined), select(isAuthenticated), - startWith(false) + startWith(false), ).subscribe((authenticated: boolean) => this._authenticated = authenticated); } @@ -119,7 +146,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('auth.errors.invalid-user')); } })); @@ -136,7 +163,7 @@ export class AuthService { options.headers = headers; options.withCredentials = true; return this.authRequestService.getRequest('status', options).pipe( - map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) + map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)), ); } @@ -202,7 +229,7 @@ export class AuthService { */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { return this.epersonService.findByHref(userHref).pipe( - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -212,7 +239,7 @@ export class AuthService { */ public retrieveAuthenticatedUserById(userId: string): Observable { return this.epersonService.findById(userId).pipe( - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -225,7 +252,24 @@ export class AuthService { select(getAuthenticatedUserId), hasValueOperator(), switchMap((id: string) => this.epersonService.findById(id)), - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), + ); + } + + /** + * Returns an observable which emits the currently authenticated user from the store, + * or null if the user is not authenticated. + */ + public getAuthenticatedUserFromStoreIfAuthenticated(): Observable { + return this.store.pipe( + select(getAuthenticatedUserId), + switchMap((id: string) => { + if (hasValue(id)) { + return this.epersonService.findById(id).pipe(getFirstSucceededRemoteDataPayload()); + } else { + return observableOf(null); + } + }), ); } @@ -248,7 +292,7 @@ export class AuthService { } else { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); } - }) + }), ); } @@ -260,15 +304,14 @@ export class AuthService { select(getAuthenticationToken), take(1), map((authTokenInfo: AuthTokenInfo) => { - let token: AuthTokenInfo; // Retrieve authentication token info and check if is valid - token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); + const token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken) && !this.isTokenExpired(token)) { return token; } else { throw false; } - }) + }), ); } @@ -412,7 +455,7 @@ export class AuthService { const token = this.getToken(); return token.expires - (60 * 5 * 1000) < Date.now(); } - }) + }), ); } @@ -437,7 +480,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; // Save cookie with the token return this.storage.set(TOKENITEM, token, options); @@ -473,10 +516,6 @@ export class AuthService { if (this._window.nativeWindow.location) { // Hard redirect to login page, so that all state is definitely lost this._window.nativeWindow.location.href = redirectUrl; - } else if (this.response) { - if (!this.response._headerSent) { - this.response.redirect(302, redirectUrl); - } } else { this.router.navigateByUrl(redirectUrl); } @@ -516,7 +555,7 @@ export class AuthService { } else { return this.storage.get(REDIRECT_COOKIE); } - }) + }), ); } @@ -529,7 +568,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; this.storage.set(REDIRECT_COOKIE, url, options); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } @@ -609,7 +648,7 @@ export class AuthService { */ getShortlivedToken(): Observable { return this.isAuthenticated().pipe( - switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)) + switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)), ); } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 1ab1d2e0a51..eba6dc89f9e 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,65 +1,64 @@ -import { Injectable } from '@angular/core'; +import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, - CanActivate, + CanActivateChildFn, + CanActivateFn, Router, RouterStateSnapshot, - UrlTree + UrlTree, } from '@angular/router'; - +import { + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { map, find, switchMap } from 'rxjs/operators'; -import { select, Store } from '@ngrx/store'; +import { + find, + map, + switchMap, +} from 'rxjs/operators'; -import { isAuthenticated, isAuthenticationLoading } from './selectors'; -import { AuthService, LOGIN_ROUTE } from './auth.service'; -import { CoreState } from '../core-state.model'; +import { AppState } from '../../app.reducer'; +import { + AuthService, + LOGIN_ROUTE, +} from './auth.service'; +import { + isAuthenticated, + isAuthenticationLoading, +} from './selectors'; /** * Prevent unauthorized activating and loading of routes - * @class AuthenticatedGuard + * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated + * @method canActivate */ -@Injectable() -export class AuthenticatedGuard implements CanActivate { - - /** - * @constructor - */ - constructor(private authService: AuthService, private router: Router, private store: Store) {} - - /** - * True when user is authenticated - * UrlTree with redirect to login page when user isn't authenticated - * @method canActivate - */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const url = state.url; - return this.handleAuth(url); - } - - /** - * True when user is authenticated - * UrlTree with redirect to login page when user isn't authenticated - * @method canActivateChild - */ - canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.canActivate(route, state); - } +export const authenticatedGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + authService: AuthService = inject(AuthService), + router: Router = inject(Router), + store: Store = inject(Store), +): Observable => { + const url = state.url; + // redirect to sign in page if user is not authenticated + return store.pipe(select(isAuthenticationLoading)).pipe( + find((isLoading: boolean) => isLoading === false), + switchMap(() => store.pipe(select(isAuthenticated))), + map((authenticated) => { + if (authenticated) { + return authenticated; + } else { + authService.setRedirectUrl(url); + authService.removeToken(); + return router.createUrlTree([LOGIN_ROUTE]); + } + }), + ); +}; - private handleAuth(url: string): Observable { - // redirect to sign in page if user is not authenticated - return this.store.pipe(select(isAuthenticationLoading)).pipe( - find((isLoading: boolean) => isLoading === false), - switchMap(() => this.store.pipe(select(isAuthenticated))), - map((authenticated) => { - if (authenticated) { - return authenticated; - } else { - this.authService.setRedirectUrl(url); - this.authService.removeToken(); - return this.router.createUrlTree([LOGIN_ROUTE]); - } - }) - ); - } -} +export const AuthenticatedGuardChild: CanActivateChildFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +) => authenticatedGuard(route, state); diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts index b41d981bcf6..9649255b236 100644 --- a/src/app/core/auth/browser-auth-request.service.spec.ts +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -1,8 +1,9 @@ -import { AuthRequestService } from './auth-request.service'; -import { RequestService } from '../data/request.service'; -import { BrowserAuthRequestService } from './browser-auth-request.service'; import { Observable } from 'rxjs'; + import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { AuthRequestService } from './auth-request.service'; +import { BrowserAuthRequestService } from './browser-auth-request.service'; describe(`BrowserAuthRequestService`, () => { let href: string; @@ -12,7 +13,7 @@ describe(`BrowserAuthRequestService`, () => { beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { - 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2', }); service = new BrowserAuthRequestService(null, requestService, null); }); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts index 485e2ef9c4a..d708cd8982a 100644 --- a/src/app/core/auth/browser-auth-request.service.ts +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -1,10 +1,14 @@ import { Injectable } from '@angular/core'; -import { AuthRequestService } from './auth-request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { PostRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthRequestService } from './auth-request.service'; /** * Client side version of the service to send authentication requests @@ -15,7 +19,7 @@ export class BrowserAuthRequestService extends AuthRequestService { constructor( halService: HALEndpointService, requestService: RequestService, - rdbService: RemoteDataBuildService + rdbService: RemoteDataBuildService, ) { super(halService, requestService, rdbService); } diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index d18b1ccf9a6..f25e5a48928 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,7 +1,17 @@ -import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { + autoserialize, + deserialize, + deserializeAs, +} from 'cerialize'; import { Observable } from 'rxjs'; -import { link, typedObject } from '../../cache/builders/build-decorators'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { PaginatedList } from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; import { EPerson } from '../../eperson/models/eperson.model'; import { EPERSON } from '../../eperson/models/eperson.resource-type'; @@ -10,12 +20,10 @@ import { GROUP } from '../../eperson/models/group.resource-type'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { AuthMethod } from './auth.method'; import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; -import { AuthMethod } from './auth.method'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { PaginatedList } from '../../data/paginated-list.model'; /** * Object that represents the authenticated status of a user @@ -43,7 +51,7 @@ export class AuthStatus implements CacheableObject { * It is based on the ID, so it will be the same for each refresh. */ @deserializeAs(new IDToUUIDSerializer('auth-status'), 'id') - uuid: string; + uuid: string; /** * True if REST API is up and running, should never return false diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 594d6d8b395..ef7a7304a06 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -3,7 +3,6 @@ export enum AuthMethodType { Shibboleth = 'shibboleth', Ldap = 'ldap', Ip = 'ip', - X509 = 'x509', 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 b84e7a308af..267f7768c9c 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -22,10 +22,6 @@ export class AuthMethod { this.location = location; break; } - case 'x509': { - this.authMethodType = AuthMethodType.X509; - break; - } case 'password': { this.authMethodType = AuthMethodType.Password; 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 3786bd8e6a0..5e8587d02dd 100644 --- a/src/app/core/auth/models/short-lived-token.model.ts +++ b/src/app/core/auth/models/short-lived-token.model.ts @@ -1,10 +1,15 @@ +import { + autoserialize, + autoserializeAs, + deserialize, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; import { SHORT_LIVED_TOKEN } from './short-lived-token.resource-type'; -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/not-authenticated.guard.spec.ts b/src/app/core/auth/not-authenticated.guard.spec.ts new file mode 100644 index 00000000000..57102b48b66 --- /dev/null +++ b/src/app/core/auth/not-authenticated.guard.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { + firstValueFrom, + of, +} from 'rxjs'; +import { PAGE_NOT_FOUND_PATH } from 'src/app/app-routing-paths'; + +import { HardRedirectService } from '../services/hard-redirect.service'; +import { AuthService } from './auth.service'; +import { notAuthenticatedGuard } from './not-authenticated.guard'; + +describe('notAuthenticatedGuard', () => { + let authService: jasmine.SpyObj; + let hardRedirectService: jasmine.SpyObj; + const mockRoute = {} as ActivatedRouteSnapshot; + const mockState = {} as RouterStateSnapshot; + + beforeEach(() => { + const authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']); + const redirectSpy = jasmine.createSpyObj('HardRedirectService', ['redirect']); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: authSpy }, + { provide: HardRedirectService, useValue: redirectSpy }, + ], + }); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + hardRedirectService = TestBed.inject(HardRedirectService) as jasmine.SpyObj; + }); + + it('should block access and redirect if user is logged in', async () => { + authService.isAuthenticated.and.returnValue(of(true)); + + const result$ = TestBed.runInInjectionContext(() => + notAuthenticatedGuard(mockRoute, mockState), + ); + + const result = await firstValueFrom(result$ as any); + expect(result).toBe(false); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(PAGE_NOT_FOUND_PATH); + }); + + it('should allow access if user is not logged in', async () => { + authService.isAuthenticated.and.returnValue(of(false)); + + const result$ = TestBed.runInInjectionContext(() => + notAuthenticatedGuard(mockRoute, mockState), + ); + + const result = await firstValueFrom(result$ as any); + expect(result).toBe(true); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/auth/not-authenticated.guard.ts b/src/app/core/auth/not-authenticated.guard.ts new file mode 100644 index 00000000000..db21a5c7a98 --- /dev/null +++ b/src/app/core/auth/not-authenticated.guard.ts @@ -0,0 +1,23 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { PAGE_NOT_FOUND_PATH } from 'src/app/app-routing-paths'; + +import { HardRedirectService } from '../services/hard-redirect.service'; +import { AuthService } from './auth.service'; + +export const notAuthenticatedGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const redirectService = inject(HardRedirectService); + + return authService.isAuthenticated().pipe( + map((isLoggedIn) => { + if (isLoggedIn) { + redirectService.redirect(PAGE_NOT_FOUND_PATH); + return false; + } + + return true; + }), + ); +}; diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index aba739edf67..63603776263 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -1,5 +1,7 @@ import { createSelector } from '@ngrx/store'; +import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; /** * Every reducer module's default export is the reducer function itself. In * addition, each module should export a type or interface that describes @@ -7,8 +9,6 @@ import { createSelector } from '@ngrx/store'; * notation packages up all of the exports into a single object. */ import { AuthState } from './auth.reducer'; -import { CoreState } from '../core-state.model'; -import { coreSelector } from '../core.selectors'; /** * Returns the user state. diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index 5b0221e5df4..b119f6b3b76 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -1,14 +1,22 @@ -import { AuthRequestService } from './auth-request.service'; +import { + HttpClient, + HttpHeaders, + HttpResponse, +} from '@angular/common/http'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { PostRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; -import { ServerAuthRequestService } from './server-auth-request.service'; -import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; -import { Observable, of as observableOf } from 'rxjs'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { PostRequest } from '../data/request.models'; import { XSRF_REQUEST_HEADER, - XSRF_RESPONSE_HEADER + XSRF_RESPONSE_HEADER, } from '../xsrf/xsrf.constants'; +import { AuthRequestService } from './auth-request.service'; +import { ServerAuthRequestService } from './server-auth-request.service'; describe(`ServerAuthRequestService`, () => { let href: string; @@ -22,20 +30,20 @@ describe(`ServerAuthRequestService`, () => { beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { - 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2', }); let headers = new HttpHeaders(); headers = headers.set(XSRF_RESPONSE_HEADER, mockToken); httpResponse = { body: { bar: false }, headers: headers, - statusText: '200' + statusText: '200', } as HttpResponse; httpClient = jasmine.createSpyObj('httpClient', { get: observableOf(httpResponse), }); halService = jasmine.createSpyObj('halService', { - 'getRootHref': '/api' + 'getRootHref': '/api', }); service = new ServerAuthRequestService(halService, requestService, null, httpClient); }); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index 058322acce0..5f1828c71c5 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -1,21 +1,22 @@ -import { Injectable } from '@angular/core'; -import { AuthRequestService } from './auth-request.service'; -import { PostRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { - HttpHeaders, HttpClient, - HttpResponse + HttpHeaders, + HttpResponse, } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { + DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, - DSPACE_XSRF_COOKIE } from '../xsrf/xsrf.constants'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { AuthRequestService } from './auth-request.service'; /** * Server side version of the service to send authentication requests @@ -45,11 +46,11 @@ export class ServerAuthRequestService extends AuthRequestService { map((response: HttpResponse) => response.headers.get(XSRF_RESPONSE_HEADER)), // Use that token to create an HttpHeaders object map((xsrfToken: string) => new HttpHeaders() - .set('Content-Type', 'application/json; charset=utf-8') - // set the token as the XSRF header - .set(XSRF_REQUEST_HEADER, xsrfToken) - // and as the DSPACE-XSRF-COOKIE - .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), + .set('Content-Type', 'application/json; charset=utf-8') + // set the token as the XSRF header + .set(XSRF_REQUEST_HEADER, xsrfToken) + // and as the DSPACE-XSRF-COOKIE + .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), map((headers: HttpHeaders) => // Create a new PostRequest using those headers and the given href new PostRequest( @@ -59,8 +60,8 @@ export class ServerAuthRequestService extends AuthRequestService { { headers: headers, }, - ) - ) + ), + ), ); } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index fc8ab18bfb6..d287604c24d 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,15 +1,42 @@ -import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; - +import { + Inject, + Injectable, + Optional, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { + REQUEST, + RESPONSE, +} from '../../../express.tokens'; +import { AppState } from '../../app.reducer'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteData } from '../data/remote-data'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { AuthService } from './auth.service'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { CookieService } from '../services/cookie.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { RouteService } from '../services/route.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + AuthService, + LOGIN_ROUTE, +} from './auth.service'; +import { AuthRequestService } from './auth-request.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { RemoteData } from '../data/remote-data'; /** * The auth service. @@ -17,6 +44,34 @@ import { RemoteData } from '../data/remote-data'; @Injectable() export class ServerAuthService extends AuthService { + constructor( + @Inject(REQUEST) protected req: any, + @Optional() @Inject(RESPONSE) private response: any, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected authRequestService: AuthRequestService, + protected epersonService: EPersonDataService, + protected router: Router, + protected routeService: RouteService, + protected storage: CookieService, + protected store: Store, + protected hardRedirectService: HardRedirectService, + protected notificationService: NotificationsService, + protected translateService: TranslateService, + ) { + super( + _window, + authRequestService, + epersonService, + router, + routeService, + storage, + store, + hardRedirectService, + notificationService, + translateService, + ); + } + /** * Returns the authenticated user * @returns {User} @@ -57,7 +112,21 @@ export class ServerAuthService extends AuthService { options.headers = headers; options.withCredentials = true; return this.authRequestService.getRequest('status', options).pipe( - map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) + map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)), ); } + + override redirectToLoginWhenTokenExpired() { + const redirectUrl = LOGIN_ROUTE + '?expired=true'; + if (this._window.nativeWindow.location) { + // Hard redirect to login page, so that all state is definitely lost + this._window.nativeWindow.location.href = redirectUrl; + } else if (this.response) { + if (!this.response._headerSent) { + this.response.redirect(302, redirectUrl); + } + } else { + this.router.navigateByUrl(redirectUrl); + } + } } diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts index a440325560a..f4244e98b2c 100644 --- a/src/app/core/auth/token-response-parsing.service.spec.ts +++ b/src/app/core/auth/token-response-parsing.service.spec.ts @@ -1,6 +1,6 @@ -import { TokenResponseParsingService } from './token-response-parsing.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { TokenResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { TokenResponseParsingService } from './token-response-parsing.service'; describe('TokenResponseParsingService', () => { let service: TokenResponseParsingService; @@ -13,10 +13,10 @@ describe('TokenResponseParsingService', () => { it('should return a TokenResponse containing the token', () => { const data = { payload: { - token: 'valid-token' + token: 'valid-token', }, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; const expected = new TokenResponse(data.payload.token, true, 200, 'OK'); expect(service.parse(undefined, data)).toEqual(expected); @@ -26,7 +26,7 @@ describe('TokenResponseParsingService', () => { const data = { payload: {}, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; const expected = new TokenResponse(null, false, 200, 'OK'); expect(service.parse(undefined, data)).toEqual(expected); @@ -36,7 +36,7 @@ describe('TokenResponseParsingService', () => { const data = { payload: {}, statusCode: 400, - statusText: 'BAD REQUEST' + statusText: 'BAD REQUEST', } as RawRestResponse; const expected = new TokenResponse(null, false, 400, 'BAD REQUEST'); expect(service.parse(undefined, data)).toEqual(expected); diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts index 1ba7a16b14e..03a45521e93 100644 --- a/src/app/core/auth/token-response-parsing.service.ts +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -1,11 +1,15 @@ -import { ResponseParsingService } from '../data/parsing.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestResponse, TokenResponse } from '../cache/response.models'; -import { isNotEmpty } from '../../shared/empty.util'; import { Injectable } from '@angular/core'; + +import { isNotEmpty } from '../../shared/empty.util'; +import { + RestResponse, + TokenResponse, +} from '../cache/response.models'; +import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/rest-request.model'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a token string * wrapped in a TokenResponse diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts index b2ddade682c..5628fe65837 100644 --- a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts @@ -1,31 +1,36 @@ -import { Injectable } from '@angular/core'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; 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 { Bitstream } from '../shared/bitstream.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { BitstreamBreadcrumbsService } from './bitstream-breadcrumbs.service'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; /** - * The class that resolves the BreadcrumbConfig object for an Item + * The resolve function that resolves the BreadcrumbConfig object for an Item */ -@Injectable({ - providedIn: 'root' -}) -export class BitstreamBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor( - protected breadcrumbService: BitstreamBreadcrumbsService, protected dataService: BitstreamDataService) { - super(breadcrumbService, dataService); - } - - /** - * 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; - } +export const bitstreamBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: BitstreamBreadcrumbsService = inject(BitstreamBreadcrumbsService), + dataService: BitstreamDataService = inject(BitstreamDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = BITSTREAM_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; +}; -} diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts index 333886ed3d1..11a3a743505 100644 --- a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts @@ -1,35 +1,46 @@ import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; - +import { getDSORoute } from '../../app-routing-paths'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; -import { DSONameService } from './dso-name.service'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; 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 { RemoteData } from '../data/remote-data'; import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { DSONameService } from './dso-name.service'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { constructor( protected bitstreamService: BitstreamDataService, protected linkService: LinkService, - protected dsoNameService: DSONameService + protected dsoNameService: DSONameService, ) { super(linkService, dsoNameService); } @@ -53,7 +64,7 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { return observableOf([]); }), - map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]), ); } @@ -74,12 +85,12 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { } else { return observableOf(undefined); } - }) + }), ); } else { return observableOf(undefined); } - }) + }), ); } } diff --git a/src/app/core/breadcrumbs/breadcrumbsProviderService.ts b/src/app/core/breadcrumbs/breadcrumbsProviderService.ts index 4f5dd0a5838..83d9a2caaa9 100644 --- a/src/app/core/breadcrumbs/breadcrumbsProviderService.ts +++ b/src/app/core/breadcrumbs/breadcrumbsProviderService.ts @@ -1,6 +1,7 @@ -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { Observable } from 'rxjs'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; + /** * Service to calculate breadcrumbs for a single part of the route */ diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts index 46c49add064..7df656a9610 100644 --- a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts @@ -1,28 +1,36 @@ -import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { Collection } from '../shared/collection.model'; -import { CollectionDataService } from '../data/collection-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { COLLECTION_PAGE_LINKS_TO_FOLLOW } from '../../collection-page/collection-page.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CollectionDataService } from '../data/collection-data.service'; +import { Collection } from '../shared/collection.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for a Collection + * The resolve function that resolves the BreadcrumbConfig object for a Collection */ -@Injectable({ - providedIn: 'root' -}) -export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CollectionDataService) { - super(breadcrumbService, dataService); - } +export const collectionBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), + dataService: CollectionDataService = inject(CollectionDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = COLLECTION_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; +}; - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return COLLECTION_PAGE_LINKS_TO_FOLLOW; - } -} diff --git a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts index 309927771d5..0c37b5ca4f2 100644 --- a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts @@ -1,28 +1,50 @@ -import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../community-page/community-page.resolver'; +import { hasValue } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { CommunityDataService } from '../data/community-data.service'; import { Community } from '../shared/community.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../community-page/community-page.resolver'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { + DSOBreadcrumbResolver, + DSOBreadcrumbResolverByUuid, +} from './dso-breadcrumb.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for a Community + * The resolve function that resolves the BreadcrumbConfig object for a Community */ -@Injectable({ - providedIn: 'root' -}) -export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CommunityDataService) { - super(breadcrumbService, dataService); - } - - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return COMMUNITY_PAGE_LINKS_TO_FOLLOW; +export const communityBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), + dataService: CommunityDataService = inject(CommunityDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = COMMUNITY_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + if (hasValue(route.data.breadcrumbQueryParam) && hasValue(route.queryParams[route.data.breadcrumbQueryParam])) { + return DSOBreadcrumbResolverByUuid( + route, + state, + route.queryParams[route.data.breadcrumbQueryParam], + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; + } else { + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; } -} +}; diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts index e35e26e46f9..c40a14a3231 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts @@ -1,12 +1,12 @@ -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { Collection } from '../shared/collection.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getTestScheduler } from 'jasmine-marbles'; -import { CollectionBreadcrumbResolver } from './collection-breadcrumb.resolver'; + +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Collection } from '../shared/collection.model'; +import { collectionBreadcrumbResolver } from './collection-breadcrumb.resolver'; describe('DSOBreadcrumbResolver', () => { describe('resolve', () => { - let resolver: DSOBreadcrumbResolver; + let resolver: any; let collectionService: any; let dsoBreadcrumbService: any; let testCollection: Collection; @@ -16,18 +16,21 @@ describe('DSOBreadcrumbResolver', () => { beforeEach(() => { uuid = '1234-65487-12354-1235'; - breadcrumbUrl = '/collections/' + uuid; - currentUrl = breadcrumbUrl + '/edit'; - testCollection = Object.assign(new Collection(), { uuid }); + breadcrumbUrl = `/collections/${uuid}`; + currentUrl = `${breadcrumbUrl}/edit`; + testCollection = Object.assign(new Collection(), { + uuid: uuid, + type: 'collection', + }); dsoBreadcrumbService = {}; collectionService = { - findById: (id: string) => createSuccessfulRemoteDataObject$(testCollection) + findById: () => createSuccessfulRemoteDataObject$(testCollection), }; - resolver = new CollectionBreadcrumbResolver(dsoBreadcrumbService, collectionService); + resolver = collectionBreadcrumbResolver; }); it('should resolve a breadcrumb config for the correct DSO', () => { - const resolvedConfig = resolver.resolve({ params: { id: uuid } } as any, { url: currentUrl } as any); + const resolvedConfig = resolver({ params: { id: uuid } } as any, { url: currentUrl } as any, dsoBreadcrumbService, collectionService); const expectedConfig = { provider: dsoBreadcrumbService, key: testCollection, url: breadcrumbUrl }; getTestScheduler().expectObservable(resolvedConfig).toBe('(a|)', { a: expectedConfig }); }); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 8be4e5e099a..992627ddfaa 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -1,56 +1,69 @@ -import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { map } from 'rxjs/operators'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { map } from 'rxjs/operators'; + +import { getDSORoute } from '../../app-routing-paths'; +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { hasValue } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for a DSpaceObject + * Method for resolving a breadcrumb config object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {DSOBreadcrumbsService} breadcrumbService + * @param {IdentifiableDataService} dataService + * @param linksToFollow + * @returns BreadcrumbConfig object */ -@Injectable({ - providedIn: 'root', -}) -export abstract class DSOBreadcrumbResolver implements Resolve> { - protected constructor( - protected breadcrumbService: DSOBreadcrumbsService, - protected dataService: IdentifiableDataService, - ) { - } +export const DSOBreadcrumbResolver: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot, breadcrumbService: DSOBreadcrumbsService, dataService: IdentifiableDataService, ...linksToFollow: FollowLinkConfig[]) => Observable> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService, + dataService: IdentifiableDataService, + ...linksToFollow: FollowLinkConfig[] +): Observable> => { + return DSOBreadcrumbResolverByUuid(route, state, route.params.id, breadcrumbService, dataService, ...linksToFollow); +}; - /** - * Method for resolving a breadcrumb config object - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns BreadcrumbConfig object - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - const uuid = route.params.id; - return this.dataService.findById(uuid, true, false, ...this.followLinks).pipe( - getFirstCompletedRemoteData(), - getRemoteDataPayload(), - map((object: T) => { - if (hasValue(object)) { - const fullPath = state.url; - const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid; - return { provider: this.breadcrumbService, key: object, url: url }; - } else { - return undefined; - } - }) - ); - } - - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - abstract get followLinks(): FollowLinkConfig[]; -} +/** + * Method for resolving a breadcrumb config object with the given UUID + * + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {String} uuid The uuid of the DSO object + * @param {DSOBreadcrumbsService} breadcrumbService + * @param {IdentifiableDataService} dataService + * @param linksToFollow + * @returns BreadcrumbConfig object + */ +export const DSOBreadcrumbResolverByUuid: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot, uuid: string, breadcrumbService: DSOBreadcrumbsService, dataService: IdentifiableDataService, ...linksToFollow: FollowLinkConfig[]) => Observable> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + uuid: string, + breadcrumbService: DSOBreadcrumbsService, + dataService: IdentifiableDataService, + ...linksToFollow: FollowLinkConfig[] +): Observable> => { + return dataService.findById(uuid, true, false, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + map((object: DSpaceObject) => { + if (hasValue(object)) { + return { provider: breadcrumbService, key: object, url: getDSORoute(object) }; + } else { + return undefined; + } + }), + ); +}; diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts index 6b89c576d66..3869defa70d 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts @@ -1,17 +1,24 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; + +import { getDSORoute } from '../../app-routing-paths'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { getMockLinkService } from '../../shared/mocks/link-service.mock'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { LinkService } from '../cache/builders/link.service'; -import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { of as observableOf } from 'rxjs'; -import { Community } from '../shared/community.model'; import { Collection } from '../shared/collection.model'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; -import { getTestScheduler } from 'jasmine-marbles'; +import { Community } from '../shared/community.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { Item } from '../shared/item.model'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { DSONameService } from './dso-name.service'; -import { getDSORoute } from '../../app-routing-paths'; describe('DSOBreadcrumbsService', () => { let service: DSOBreadcrumbsService; @@ -43,46 +50,46 @@ describe('DSOBreadcrumbsService', () => { { type: 'community', metadata: { - 'dc.title': [{ value: 'community' }] + 'dc.title': [{ value: 'community' }], }, uuid: communityUUID, parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), _links: { parentCommunity: 'site', - self: communityPath + communityUUID - } - } + self: communityPath + communityUUID, + }, + }, ); testCollection = Object.assign(new Collection(), { type: 'collection', metadata: { - 'dc.title': [{ value: 'collection' }] + 'dc.title': [{ value: 'collection' }], }, uuid: collectionUUID, parentCommunity: createSuccessfulRemoteDataObject$(testCommunity), _links: { parentCommunity: communityPath + communityUUID, - self: communityPath + collectionUUID - } - } + self: communityPath + collectionUUID, + }, + }, ); testItem = Object.assign(new Item(), { type: 'item', metadata: { - 'dc.title': [{ value: 'item' }] + 'dc.title': [{ value: 'item' }], }, uuid: itemUUID, owningCollection: createSuccessfulRemoteDataObject$(testCollection), _links: { owningCollection: collectionPath + collectionUUID, - self: itemPath + itemUUID - } - } + self: itemPath + itemUUID, + }, + }, ); dsoNameService = { getName: (dso) => getName(dso) }; @@ -93,8 +100,8 @@ describe('DSOBreadcrumbsService', () => { TestBed.configureTestingModule({ providers: [ { provide: LinkService, useValue: getMockLinkService() }, - { provide: DSONameService, useValue: dsoNameService } - ] + { provide: DSONameService, useValue: dsoNameService }, + ], }).compileComponents(); })); diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 9a22cd0e357..7d2e3697e18 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -1,27 +1,35 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + find, + map, + switchMap, +} from 'rxjs/operators'; + +import { getDSORoute } from '../../app-routing-paths'; import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; -import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; -import { DSONameService } from './dso-name.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { LinkService } from '../cache/builders/link.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; +import { hasValue } from '../../shared/empty.util'; import { followLink } from '../../shared/utils/follow-link-config.model'; -import { find, map, switchMap } from 'rxjs/operators'; +import { LinkService } from '../cache/builders/link.service'; import { RemoteData } from '../data/remote-data'; -import { hasValue } from '../../shared/empty.util'; -import { Injectable } from '@angular/core'; -import { getDSORoute } from '../../app-routing-paths'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { DSONameService } from './dso-name.service'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DSOBreadcrumbsService implements BreadcrumbsProviderService { constructor( protected linkService: LinkService, - protected dsoNameService: DSONameService + protected dsoNameService: DSONameService, ) { } @@ -46,7 +54,7 @@ export class DSOBreadcrumbsService implements BreadcrumbsProviderService [...breadcrumbs, crumb]) + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]), ); } } diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 9f2f76599af..4bdd36a0c89 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -9,6 +9,10 @@ describe(`DSONameService`, () => { let service: DSONameService; let mockPersonName: string; let mockPerson: DSpaceObject; + let mockEPersonNameFirst: string; + let mockEPersonFirst: DSpaceObject; + let mockEPersonName: string; + let mockEPerson: DSpaceObject; let mockOrgUnitName: string; let mockOrgUnit: DSpaceObject; let mockDSOName: string; @@ -22,7 +26,27 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return ['Person', Item, DSpaceObject]; - } + }, + }); + + mockEPersonName = 'John Doe'; + mockEPerson = Object.assign(new DSpaceObject(), { + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return mockEPersonName; + }, + getRenderTypes(): (string | GenericConstructor)[] { + return ['EPerson', Item, DSpaceObject]; + }, + }); + + mockEPersonNameFirst = 'John'; + mockEPersonFirst = Object.assign(new DSpaceObject(), { + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return mockEPersonNameFirst; + }, + getRenderTypes(): (string | GenericConstructor)[] { + return ['EPerson', Item, DSpaceObject]; + }, }); mockOrgUnitName = 'Molecular Spectroscopy'; @@ -32,7 +56,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return ['OrgUnit', Item, DSpaceObject]; - } + }, }); mockDSOName = 'Lorem Ipsum'; @@ -42,7 +66,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return [DSpaceObject]; - } + }, }); service = new DSONameService({ instant: (a) => a } as any); @@ -54,7 +78,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockPerson); - expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson); + expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson, undefined); expect(result).toBe('Bingo!'); }); @@ -63,7 +87,16 @@ describe(`DSONameService`, () => { const result = service.getName(mockOrgUnit); - expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit); + expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit, undefined); + expect(result).toBe('Bingo!'); + }); + + it(`should use the EPerson factory for EPerson objects`, () => { + spyOn((service as any).factories, 'EPerson').and.returnValue('Bingo!'); + + const result = service.getName(mockEPerson); + + expect((service as any).factories.EPerson).toHaveBeenCalledWith(mockEPerson, undefined); expect(result).toBe('Bingo!'); }); @@ -72,7 +105,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockDSO); - expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO); + expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO, undefined); expect(result).toBe('Bingo!'); }); }); @@ -86,9 +119,9 @@ describe(`DSONameService`, () => { 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'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName', undefined, undefined); + expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); @@ -100,13 +133,42 @@ describe(`DSONameService`, () => { 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'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); }); + describe(`factories.EPerson`, () => { + describe(`with eperson.firstname and without eperson.lastname`, () => { + beforeEach(() => { + spyOn(mockEPerson, 'firstMetadataValue').and.returnValues(...mockEPersonName.split(' ')); + }); + + it(`should return 'eperson.firstname' and 'eperson.lastname'`, () => { + const result = (service as any).factories.EPerson(mockEPerson); + expect(result).toBe(mockEPersonName); + expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname', undefined, undefined); + expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname', undefined, undefined); + }); + }); + + describe(` with eperson.firstname and without eperson.lastname`, () => { + beforeEach(() => { + spyOn(mockEPersonFirst, 'firstMetadataValue').and.returnValues(mockEPersonNameFirst, undefined); + }); + + it(`should return 'eperson.firstname'`, () => { + const result = (service as any).factories.EPerson(mockEPersonFirst); + expect(result).toBe(mockEPersonNameFirst); + expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname', undefined, undefined); + expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname', undefined, undefined); + }); + }); + }); + + describe(`factories.OrgUnit`, () => { beforeEach(() => { spyOn(mockOrgUnit, 'firstMetadataValue').and.callThrough(); @@ -115,7 +177,7 @@ describe(`DSONameService`, () => { it(`should return 'organization.legalName'`, () => { const result = (service as any).factories.OrgUnit(mockOrgUnit); expect(result).toBe(mockOrgUnitName); - expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName'); + expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName', undefined, undefined); }); }); @@ -127,7 +189,7 @@ describe(`DSONameService`, () => { it(`should return 'dc.title'`, () => { const result = (service as any).factories.Default(mockDSO); expect(result).toBe(mockDSOName); - expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); }); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index ddd97705b01..b7daa8dd4e2 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@angular/core'; -import { hasValue, isEmpty } from '../../shared/empty.util'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; + +import { + hasValue, + isEmpty, +} from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Metadata } from '../shared/metadata.utils'; /** @@ -9,7 +13,7 @@ import { Metadata } from '../shared/metadata.utils'; * on its render types. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DSONameService { @@ -27,9 +31,9 @@ export class DSONameService { * With only two exceptions those solutions seem overkill for now. */ private readonly factories = { - EPerson: (dso: DSpaceObject): string => { - const firstName = dso.firstMetadataValue('eperson.firstname'); - const lastName = dso.firstMetadataValue('eperson.lastname'); + EPerson: (dso: DSpaceObject, escapeHTML?: boolean): string => { + const firstName = dso.firstMetadataValue('eperson.firstname', undefined, escapeHTML); + const lastName = dso.firstMetadataValue('eperson.lastname', undefined, escapeHTML); if (isEmpty(firstName) && isEmpty(lastName)) { return this.translateService.instant('dso.name.unnamed'); } else if (isEmpty(firstName) || isEmpty(lastName)) { @@ -38,32 +42,33 @@ export class DSONameService { return `${firstName} ${lastName}`; } }, - Person: (dso: DSpaceObject): string => { - const familyName = dso.firstMetadataValue('person.familyName'); - const givenName = dso.firstMetadataValue('person.givenName'); + Person: (dso: DSpaceObject, escapeHTML?: boolean): string => { + const familyName = dso.firstMetadataValue('person.familyName', undefined, escapeHTML); + const givenName = dso.firstMetadataValue('person.givenName', undefined, escapeHTML); if (isEmpty(familyName) && isEmpty(givenName)) { - return dso.firstMetadataValue('dc.title') || this.translateService.instant('dso.name.unnamed'); + return dso.firstMetadataValue('dc.title', undefined, escapeHTML) || this.translateService.instant('dso.name.unnamed'); } else if (isEmpty(familyName) || isEmpty(givenName)) { return familyName || givenName; } else { return `${familyName}, ${givenName}`; } }, - OrgUnit: (dso: DSpaceObject): string => { - return dso.firstMetadataValue('organization.legalName'); + OrgUnit: (dso: DSpaceObject, escapeHTML?: boolean): string => { + return dso.firstMetadataValue('organization.legalName', undefined, escapeHTML); }, - Default: (dso: DSpaceObject): string => { + Default: (dso: DSpaceObject, escapeHTML?: boolean): string => { // If object doesn't have dc.title metadata use name property - return dso.firstMetadataValue('dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); - } + return dso.firstMetadataValue('dc.title', undefined, escapeHTML) || dso.name || this.translateService.instant('dso.name.untitled'); + }, }; /** * Get the name for the given {@link DSpaceObject} * * @param dso The {@link DSpaceObject} you want a name for + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute */ - getName(dso: DSpaceObject | undefined): string { + getName(dso: DSpaceObject | undefined, escapeHTML?: boolean): string { if (dso) { const types = dso.getRenderTypes(); const match = types @@ -72,10 +77,10 @@ export class DSONameService { let name; if (hasValue(match)) { - name = this.factories[match](dso); + name = this.factories[match](dso, escapeHTML); } if (isEmpty(name)) { - name = this.factories.Default(dso); + name = this.factories.Default(dso, escapeHTML); } return name; } else { @@ -88,27 +93,28 @@ export class DSONameService { * * @param object * @param dso + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * * @returns {string} html embedded hit highlight. */ - getHitHighlights(object: any, dso: DSpaceObject): string { + getHitHighlights(object: any, dso: DSpaceObject, escapeHTML?: boolean): string { const types = dso.getRenderTypes(); const entityType = types .filter((type) => typeof type === 'string') .find((type: string) => (['Person', 'OrgUnit']).includes(type)) as string; if (entityType === 'Person') { - const familyName = this.firstMetadataValue(object, dso, 'person.familyName'); - const givenName = this.firstMetadataValue(object, dso, 'person.givenName'); + const familyName = this.firstMetadataValue(object, dso, 'person.familyName', escapeHTML); + const givenName = this.firstMetadataValue(object, dso, 'person.givenName', escapeHTML); if (isEmpty(familyName) && isEmpty(givenName)) { - return this.firstMetadataValue(object, dso, 'dc.title') || dso.name; + return this.firstMetadataValue(object, dso, 'dc.title', escapeHTML) || dso.name; } else if (isEmpty(familyName) || isEmpty(givenName)) { return familyName || givenName; } return `${familyName}, ${givenName}`; } else if (entityType === 'OrgUnit') { - return this.firstMetadataValue(object, dso, 'organization.legalName'); + return this.firstMetadataValue(object, dso, 'organization.legalName', escapeHTML); } - return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); + return this.firstMetadataValue(object, dso, 'dc.title', escapeHTML) || dso.name || this.translateService.instant('dso.name.untitled'); } /** @@ -117,11 +123,12 @@ export class DSONameService { * @param object * @param dso * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[]): string { - return Metadata.firstValue([object.hitHighlights, dso.metadata], keyOrKeys); + firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[], escapeHTML?: boolean): string { + return Metadata.firstValue(dso.metadata, keyOrKeys, object.hitHighlights, undefined, escapeHTML); } } diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts index 0d1870487ab..a85338c4908 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts @@ -1,9 +1,9 @@ -import { I18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { i18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; -describe('I18nBreadcrumbResolver', () => { +describe('i18nBreadcrumbResolver', () => { describe('resolve', () => { - let resolver: I18nBreadcrumbResolver; + let resolver: any; let i18nBreadcrumbService: any; let i18nKey: string; let route: any; @@ -17,28 +17,28 @@ describe('I18nBreadcrumbResolver', () => { route = { data: { breadcrumbKey: i18nKey }, routeConfig: { - path: segment + path: segment, }, parent: { routeConfig: { - path: parentSegment - } - } as any + path: parentSegment, + }, + } as any, }; expectedPath = new URLCombiner(parentSegment, segment).toString(); i18nBreadcrumbService = {}; - resolver = new I18nBreadcrumbResolver(i18nBreadcrumbService); + resolver = i18nBreadcrumbResolver; }); it('should resolve the breadcrumb config', () => { - const resolvedConfig = resolver.resolve(route, {} as any); + const resolvedConfig = resolver(route, {} as any, i18nBreadcrumbService); const expectedConfig = { provider: i18nBreadcrumbService, key: i18nKey, url: expectedPath }; expect(resolvedConfig).toEqual(expectedConfig); }); it('should resolve throw an error when no breadcrumbKey is defined', () => { expect(() => { - resolver.resolve({ data: {} } as any, undefined); + resolver({ data: {} } as any, undefined, i18nBreadcrumbService); }).toThrow(); }); }); diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts index b3fadbbaa93..5f5c779211f 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts @@ -1,32 +1,31 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; import { hasNoValue } from '../../shared/empty.util'; import { currentPathFromSnapshot } from '../../shared/utils/route.utils'; +import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; /** - * The class that resolves a BreadcrumbConfig object with an i18n key string for a route + * Method for resolving an I18n breadcrumb configuration object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {I18nBreadcrumbsService} breadcrumbService + * @returns BreadcrumbConfig object */ -@Injectable({ - providedIn: 'root' -}) -export class I18nBreadcrumbResolver implements Resolve> { - constructor(protected breadcrumbService: I18nBreadcrumbsService) { - } - - /** - * Method for resolving an I18n breadcrumb configuration object - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns BreadcrumbConfig object - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { - const key = route.data.breadcrumbKey; - if (hasNoValue(key)) { - throw new Error('You provided an i18nBreadcrumbResolver for url \"' + route.url + '\" but no breadcrumbKey in the route\'s data'); - } - const fullPath = currentPathFromSnapshot(route); - return { provider: this.breadcrumbService, key: key, url: fullPath }; +export const i18nBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: I18nBreadcrumbsService = inject(I18nBreadcrumbsService), +): BreadcrumbConfig => { + const key = route.data.breadcrumbKey; + if (hasNoValue(key)) { + throw new Error('You provided an i18nBreadcrumbResolver for url \"' + route.url + '\" but no breadcrumbKey in the route\'s data'); } -} + const fullPath = currentPathFromSnapshot(route); + return { provider: breadcrumbService, key: key, url: fullPath }; +}; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts index ac2f2440370..3fcd911a464 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts @@ -1,7 +1,14 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { getTestScheduler } from 'jasmine-marbles'; -import { BREADCRUMB_MESSAGE_POSTFIX, I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + BREADCRUMB_MESSAGE_POSTFIX, + I18nBreadcrumbsService, +} from './i18n-breadcrumbs.service'; describe('I18nBreadcrumbsService', () => { let service: I18nBreadcrumbsService; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts index 15563bdde8e..5746f6faf26 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts @@ -1,7 +1,11 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; -import { Observable, of as observableOf } from 'rxjs'; -import { Injectable } from '@angular/core'; /** * The postfix for i18n breadcrumbs @@ -12,7 +16,7 @@ export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; * Service to calculate i18n breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class I18nBreadcrumbsService implements BreadcrumbsProviderService { diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts index 3005b6f09ac..ef021123d42 100644 --- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -1,28 +1,35 @@ -import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { getItemPageLinksToFollow } from '../../item-page/item.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { ItemDataService } from '../data/item-data.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item-page/item.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** - * The class that resolves the BreadcrumbConfig object for an Item + * The resolve function that resolves the BreadcrumbConfig object for an Item */ -@Injectable({ - providedIn: 'root' -}) -export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: ItemDataService) { - super(breadcrumbService, dataService); - } - - /** - * Method that returns the follow links to already resolve - * The self links defined in this list are expected to be requested somewhere in the near future - * Requesting them as embeds will limit the number of requests - */ - get followLinks(): FollowLinkConfig[] { - return ITEM_PAGE_LINKS_TO_FOLLOW; - } -} +export const itemBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), + dataService: ItemDataService = inject(ItemDataService), +): Observable> => { + const linksToFollow: FollowLinkConfig[] = getItemPageLinksToFollow() as FollowLinkConfig[]; + return DSOBreadcrumbResolver( + route, + state, + breadcrumbService, + dataService, + ...linksToFollow, + ) as Observable>; +}; diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..a6bbe49ddd9 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts @@ -0,0 +1,52 @@ +import { navigationBreadcrumbResolver } from './navigation-breadcrumb.resolver'; + +describe('navigationBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: any; + let NavigationBreadcrumbService: any; + let i18nKey: string; + let relatedI18nKey: string; + let route: any; + let expectedPath; + let state; + beforeEach(() => { + i18nKey = 'example.key'; + relatedI18nKey = 'related.key'; + route = { + data: { + breadcrumbKey: i18nKey, + relatedRoutes: [ + { + path: '', + data: { breadcrumbKey: relatedI18nKey }, + }, + ], + }, + routeConfig: { + path: 'example', + }, + parent: { + routeConfig: { + path: '', + }, + url: [{ + path: 'base', + }], + } as any, + }; + + state = { + url: '/base/example', + }; + expectedPath = '/base/example:/base'; + NavigationBreadcrumbService = {}; + resolver = navigationBreadcrumbResolver; + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver(route, state, NavigationBreadcrumbService); + const expectedConfig = { provider: NavigationBreadcrumbService, key: `${i18nKey}:${relatedI18nKey}`, url: expectedPath }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts new file mode 100644 index 00000000000..ac306ee3f5c --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts @@ -0,0 +1,52 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +/** + * Method for resolving an I18n breadcrumb configuration object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {NavigationBreadcrumbsService} breadcrumbService + * @returns BreadcrumbConfig object + */ +export const navigationBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: NavigationBreadcrumbsService = inject(NavigationBreadcrumbsService), +): BreadcrumbConfig => { + const parentRoutes: ActivatedRouteSnapshot[] = []; + getParentRoutes(route, parentRoutes); + const relatedRoutes = route.data.relatedRoutes; + const parentPaths = parentRoutes.map(parent => parent.routeConfig?.path); + const relatedParentRoutes = relatedRoutes.filter(relatedRoute => parentPaths.includes(relatedRoute.path)); + const baseUrlSegmentPath = route.parent.url[route.parent.url.length - 1].path; + const baseUrl = state.url.substring(0, state.url.lastIndexOf(baseUrlSegmentPath) + baseUrlSegmentPath.length); + + + const combinedParentBreadcrumbKeys = relatedParentRoutes.reduce((previous, current) => { + return `${previous}:${current.data.breadcrumbKey}`; + }, route.data.breadcrumbKey); + const combinedUrls = relatedParentRoutes.reduce((previous, current) => { + return `${previous}:${baseUrl}${current.path}`; + }, state.url); + + return { provider: breadcrumbService, key: combinedParentBreadcrumbKeys, url: combinedUrls }; +}; + +/** + * Method to collect all parent routes snapshot from current route snapshot + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {ActivatedRouteSnapshot[]} parentRoutes + */ +function getParentRoutes(route: ActivatedRouteSnapshot, parentRoutes: ActivatedRouteSnapshot[]): void { + if (route.parent) { + parentRoutes.push(route.parent); + getParentRoutes(route.parent, parentRoutes); + } +} diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts new file mode 100644 index 00000000000..2da8b06eab7 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; + +/** + * The postfix for i18n breadcrumbs + */ +export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; + +/** + * Service to calculate i18n breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root', +}) +export class NavigationBreadcrumbsService implements BreadcrumbsProviderService { + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: string, url: string): Observable { + const keys = key.split(':'); + const urls = url.split(':'); + const breadcrumbs = keys.map((currentKey, index) => new Breadcrumb(currentKey + BREADCRUMB_MESSAGE_POSTFIX, urls[index] )); + return observableOf(breadcrumbs.reverse()); + } +} diff --git a/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts new file mode 100644 index 00000000000..646b967fe5b --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts @@ -0,0 +1,47 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BREADCRUMB_MESSAGE_POSTFIX } from './i18n-breadcrumbs.service'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +describe('NavigationBreadcrumbsService', () => { + let service: NavigationBreadcrumbsService; + let exampleString; + let exampleURL; + let childrenString; + let childrenUrl; + let parentString; + let parentUrl; + + function init() { + exampleString = 'example.string:parent.string'; + exampleURL = 'example.com:parent.com'; + childrenString = 'example.string'; + childrenUrl = 'example.com'; + parentString = 'parent.string'; + parentUrl = 'parent.com'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new NavigationBreadcrumbsService(); + }); + + describe('getBreadcrumbs', () => { + it('should return an array of breadcrumbs based on strings by adding the postfix', () => { + const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [ + new Breadcrumb(childrenString + BREADCRUMB_MESSAGE_POSTFIX, childrenUrl), + new Breadcrumb(parentString + BREADCRUMB_MESSAGE_POSTFIX, parentUrl), + ].reverse() }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..7c2c34d4790 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import { publicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; + +describe('publicationClaimBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: any; + let publicationClaimBreadcrumbService: any; + const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedId = 'openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + let route; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + targetId: expectedId, + }, + }; + publicationClaimBreadcrumbService = {}; + resolver = publicationClaimBreadcrumbResolver; + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver(route as any, { url: fullPath } as any, publicationClaimBreadcrumbService); + const expectedConfig = { provider: publicationClaimBreadcrumbService, key: expectedKey }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts new file mode 100644 index 00000000000..a1b52ce333f --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts @@ -0,0 +1,18 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +export const publicationClaimBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: PublicationClaimBreadcrumbService = inject(PublicationClaimBreadcrumbService), +): BreadcrumbConfig => { + const targetId = route.paramMap.get('targetId').split(':')[1]; + return { provider: breadcrumbService, key: targetId }; +}; diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts new file mode 100644 index 00000000000..8424b5edda8 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts @@ -0,0 +1,55 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of } from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +describe('PublicationClaimBreadcrumbService', () => { + let service: PublicationClaimBreadcrumbService; + let dsoNameService: any = { + getName: (str) => str, + }; + let translateService: any = { + instant: (str) => str, + }; + + let dataService: any = { + findById: (str) => createSuccessfulRemoteDataObject$(str), + }; + + let authorizationService: any = { + isAuthorized: (str) => of(true), + }; + + let exampleKey; + + const ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + const ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + function init() { + exampleKey = 'suggestion.suggestionFor.breadcrumb'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new PublicationClaimBreadcrumbService(dataService,dsoNameService,translateService, authorizationService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleKey); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY, ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(exampleKey, undefined)], + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts new file mode 100644 index 00000000000..43b7ed5761b --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../data/feature-authorization/feature-id'; +import { ItemDataService } from '../data/item-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { DSONameService } from './dso-name.service'; + +/** + * Service to calculate Publication claims breadcrumbs + */ +@Injectable({ + providedIn: 'root', +}) +export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService { + private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + private ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + constructor(private dataService: ItemDataService, + private dsoNameService: DSONameService, + private tranlsateService: TranslateService, + protected authorizationService: AuthorizationDataService) { + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + */ + getBreadcrumbs(key: string): Observable { + return combineLatest([this.dataService.findById(key).pipe(getFirstCompletedRemoteData()),this.authorizationService.isAuthorized(FeatureID.AdministratorOf)]).pipe( + map(([item, isAdmin]) => { + const itemName = this.dsoNameService.getName(item.payload); + return isAdmin ? [new Breadcrumb(this.tranlsateService.instant(this.ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY), this.ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', { name: itemName }), undefined)] : + [new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', { name: itemName }), undefined)]; + }), + ); + } +} diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..fe2fe77e7f4 --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import { qualityAssuranceBreadcrumbResolver } from './quality-assurance-breadcrumb.resolver'; + +describe('qualityAssuranceBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: any; + let qualityAssuranceBreadcrumbService: any; + let route: any; + const fullPath = '/test/quality-assurance/'; + const expectedKey = 'testSourceId:testTopicId'; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + sourceId: 'testSourceId', + topicId: 'testTopicId', + }, + }; + qualityAssuranceBreadcrumbService = {}; + resolver = qualityAssuranceBreadcrumbResolver; + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, qualityAssuranceBreadcrumbService); + const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts new file mode 100644 index 00000000000..6507a75de66 --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts @@ -0,0 +1,27 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; + +export const qualityAssuranceBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: QualityAssuranceBreadcrumbService = inject(QualityAssuranceBreadcrumbService), +): BreadcrumbConfig => { + const sourceId = route.paramMap.get('sourceId'); + const topicId = route.paramMap.get('topicId'); + let key = sourceId; + + if (topicId) { + key += `:${topicId}`; + } + const fullPath = state.url; + const url = fullPath.substring(0, fullPath.indexOf(sourceId)); + + return { provider: breadcrumbService, key, url }; +}; diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts new file mode 100644 index 00000000000..f8d30754cad --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts @@ -0,0 +1,43 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; + +describe('QualityAssuranceBreadcrumbService', () => { + let service: QualityAssuranceBreadcrumbService; + let translateService: any = { + instant: (str) => str, + }; + + let exampleString; + let exampleURL; + let exampleQaKey; + + function init() { + exampleString = 'sourceId'; + exampleURL = '/test/quality-assurance/'; + exampleQaKey = 'admin.quality-assurance.breadcrumbs'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new QualityAssuranceBreadcrumbService(translateService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL), + new Breadcrumb(exampleString, exampleURL + exampleString)], + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts new file mode 100644 index 00000000000..580a5e5f8ee --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; + +/** + * Service to calculate QA breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root', +}) +export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService { + + private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs'; + constructor( + private translationService: TranslateService, + ) { + + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: string, url: string): Observable { + const args = key.split(':'); + const sourceId = args[0]; + const topicId = args.length > 2 ? args[args.length - 1] : args[1]; + + if (topicId) { + return observableOf( [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + new Breadcrumb(sourceId, `${url}${sourceId}`), + new Breadcrumb(topicId, undefined)]); + } else { + return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + new Breadcrumb(sourceId, `${url}${sourceId}`)]); + } + + } +} diff --git a/src/app/core/browse/browse-definition-data.service.spec.ts b/src/app/core/browse/browse-definition-data.service.spec.ts index f321c2551cd..affa63a5480 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -1,11 +1,12 @@ -import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { EMPTY } from 'rxjs'; -import { FindListOptions } from '../data/find-list-options.model'; + +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { RequestService } from '../data/request.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestService } from '../data/request.service'; +import { BrowseDefinitionDataService } from './browse-definition-data.service'; describe(`BrowseDefinitionDataService`, () => { let requestService: RequestService; @@ -18,7 +19,7 @@ describe(`BrowseDefinitionDataService`, () => { const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), - followLink('items') + followLink('items'), ]; function initTestService() { diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index bc495a51f4f..9c0d0d16c95 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,33 +1,45 @@ // eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; -import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { RequestService } from '../data/request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { take } 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 { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { RemoteData } from '../data/remote-data'; -import { PaginatedList } from '../data/paginated-list.model'; -import { FindListOptions } from '../data/find-list-options.model'; +import { + FindAllData, + FindAllDataImpl, +} from '../data/base/find-all-data'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; -import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; -import { dataService } from '../data/base/data-service.decorator'; -import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; -import { take } from 'rxjs/operators'; +import { + SearchData, + SearchDataImpl, +} from '../data/base/search-data'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; import { BrowseDefinitionRestRequest } from '../data/request.models'; -import { RequestParam } from '../cache/models/request-param.model'; -import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; /** * Create a GET request for the given href, and send it. * Use a GET request specific for BrowseDefinitions. */ export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, - responseMsToLive: number, - href$: string | Observable, - useCachedVersionIfAvailable: boolean = true): void => { + responseMsToLive: number, + href$: string | Observable, + useCachedVersionIfAvailable: boolean = true): void => { if (isNotEmpty(href$)) { if (typeof href$ === 'string') { href$ = observableOf(href$); @@ -35,7 +47,7 @@ export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestS href$.pipe( isNotEmptyOperator(), - take(1) + take(1), ).subscribe((href: string) => { const requestId = requestService.generateRequestId(); const request = new BrowseDefinitionRestRequest(requestId, href); @@ -62,7 +74,6 @@ class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl @Injectable({ providedIn: 'root', }) -@dataService(BROWSE_DEFINITION) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { private findAllData: BrowseDefinitionFindAllDataImpl; private searchData: SearchDataImpl; @@ -150,7 +161,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService { let scheduler: TestScheduler; let service: BrowseService; let requestService: RequestService; - let rdbService: RemoteDataBuildService; const browsesEndpointURL = 'https://rest.api/browses'; const halService: any = new HALEndpointServiceStub(browsesEndpointURL); @@ -31,26 +39,26 @@ describe('BrowseService', () => { sortOptions: [ { name: 'title', - metadata: 'dc.title' + metadata: 'dc.title', }, { name: 'dateissued', - metadata: 'dc.date.issued' + metadata: 'dc.date.issued', }, { name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } + metadata: 'dc.date.accessioned', + }, ], defaultSortOrder: 'ASC', type: 'browse', metadataKeys: [ - 'dc.date.issued' + 'dc.date.issued', ], _links: { self: { href: 'https://rest.api/discover/browses/dateissued' }, - items: { href: 'https://rest.api/discover/browses/dateissued/items' } - } + items: { href: 'https://rest.api/discover/browses/dateissued/items' }, + }, }), Object.assign(new ValueListBrowseDefinition(), { id: 'author', @@ -58,28 +66,28 @@ describe('BrowseService', () => { sortOptions: [ { name: 'title', - metadata: 'dc.title' + metadata: 'dc.title', }, { name: 'dateissued', - metadata: 'dc.date.issued' + metadata: 'dc.date.issued', }, { name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } + metadata: 'dc.date.accessioned', + }, ], defaultSortOrder: 'ASC', type: 'browse', metadataKeys: [ 'dc.contributor.*', - 'dc.creator' + 'dc.creator', ], _links: { self: { href: 'https://rest.api/discover/browses/author' }, entries: { href: 'https://rest.api/discover/browses/author/entries' }, - items: { href: 'https://rest.api/discover/browses/author/items' } - } + items: { href: 'https://rest.api/discover/browses/author/items' }, + }, }), Object.assign(new HierarchicalBrowseDefinition(), { id: 'srsc', @@ -88,14 +96,14 @@ describe('BrowseService', () => { vocabulary: 'srsc', type: 'browse', metadata: [ - 'dc.subject' + 'dc.subject', ], _links: { vocabulary: { 'href': 'https://rest.api/submission/vocabularies/srsc/' }, items: { 'href': 'https://rest.api/discover/browses/srsc/items' }, entries: { 'href': 'https://rest.api/discover/browses/srsc/entries' }, - self: { 'href': 'https://rest.api/discover/browses/srsc' } - } + self: { 'href': 'https://rest.api/discover/browses/srsc' }, + }, }), ]; @@ -104,13 +112,13 @@ describe('BrowseService', () => { const getRequestEntry$ = (successful: boolean) => { return observableOf({ - response: { isSuccessful: successful, payload: browseDefinitions } as any + response: { isSuccessful: successful, payload: browseDefinitions } as any, } as RequestEntry); }; function initTestService() { browseDefinitionDataService = jasmine.createSpyObj('browseDefinitionDataService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(browseDefinitions)) + findAll: createSuccessfulRemoteDataObject$(createPaginatedList(browseDefinitions)), }); hrefOnlyDataService = getMockHrefOnlyDataService(); return new BrowseService( @@ -118,7 +126,6 @@ describe('BrowseService', () => { halService, browseDefinitionDataService, hrefOnlyDataService, - rdbService ); } @@ -130,11 +137,9 @@ describe('BrowseService', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(halService, 'getEndpoint').and .returnValue(hot('--a-', { a: browsesEndpointURL })); - spyOn(rdbService, 'buildList').and.callThrough(); }); it('should call BrowseDefinitionDataService to create the RemoteData Observable', () => { @@ -151,9 +156,7 @@ describe('BrowseService', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); - spyOn(rdbService, 'buildList').and.callThrough(); }); describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { @@ -164,7 +167,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); @@ -178,7 +181,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); @@ -193,7 +196,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); }); @@ -204,11 +207,10 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions fires', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('--a-', { - a: createSuccessfulRemoteDataObject(createPaginatedList(browseDefinitions)) + a: createSuccessfulRemoteDataObject(createPaginatedList(browseDefinitions)), })); }); @@ -259,7 +261,6 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions doesn\'t fire', () => { it('should return undefined', () => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('----')); @@ -277,9 +278,7 @@ describe('BrowseService', () => { describe('getFirstItemFor', () => { beforeEach(() => { requestService = getMockRequestService(); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); - spyOn(rdbService, 'buildList').and.callThrough(); }); describe('when getFirstItemFor is called with a valid browse definition id', () => { @@ -290,7 +289,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expectedURL + a: expectedURL, })); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index b210b349494..5fe06a700e5 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,39 +1,57 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { + distinctUntilChanged, + map, + startWith, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; +import { SortDirection } from '../cache/models/sort-options.model'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; -import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { getBrowseDefinitionLinks, getFirstOccurrence, - getRemoteDataPayload, getFirstSucceededRemoteData, - getPaginatedListPayload + getPaginatedListPayload, + getRemoteDataPayload, } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { HrefOnlyDataService } from '../data/href-only-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { SortDirection } from '../cache/models/sort-options.model'; - +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('thumbnail') -]; +export function getBrowseLinksToFollow(): FollowLinkConfig[] { + const followLinks = [ + followLink('thumbnail'), + ]; + if (environment.item.showAccessStatuses) { + followLinks.push(followLink('accessStatus')); + } + return followLinks; +} /** * The service handling all browse requests */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class BrowseService { protected linkPath = 'browses'; @@ -55,7 +73,6 @@ export class BrowseService { protected halService: HALEndpointService, private browseDefinitionDataService: BrowseDefinitionDataService, private hrefOnlyDataService: HrefOnlyDataService, - private rdb: RemoteDataBuildService, ) { } @@ -102,10 +119,10 @@ export class BrowseService { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; - }) + }), ); if (options.fetchThumbnail ) { - return this.hrefOnlyDataService.findListByHref(href$, {}, null, null, ...BROWSE_LINKS_TO_FOLLOW); + return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...getBrowseLinksToFollow()); } return this.hrefOnlyDataService.findListByHref(href$); } @@ -153,7 +170,7 @@ export class BrowseService { }), ); if (options.fetchThumbnail) { - return this.hrefOnlyDataService.findListByHref(href$, {}, null, null, ...BROWSE_LINKS_TO_FOLLOW); + return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...getBrowseLinksToFollow()); } return this.hrefOnlyDataService.findListByHref(href$); } @@ -187,12 +204,12 @@ export class BrowseService { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; - }) + }), ); return this.hrefOnlyDataService.findListByHref(href$).pipe( getFirstSucceededRemoteData(), - getFirstOccurrence() + getFirstOccurrence(), ); } @@ -248,7 +265,7 @@ export class BrowseService { } return isNotEmpty(matchingKeys); - }) + }), ), map((def: BrowseDefinition) => { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { @@ -258,7 +275,7 @@ export class BrowseService { } }), startWith(undefined), - distinctUntilChanged() + distinctUntilChanged(), ); } diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 150a07f0066..53f4cb2f7f3 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,7 +1,12 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { getLinkDefinition, link } from './build-decorators'; +import { + dataService, + getDataServiceFor, + getLinkDefinition, + link, +} from './build-decorators'; class TestHALResource implements HALResource { _links: { @@ -46,5 +51,17 @@ describe('build decorators', () => { expect(result).toBeUndefined(); }); }); + + describe(`set data service`, () => { + it(`should throw error`, () => { + expect(dataService(null)).toThrow(); + }); + + it(`should set properly data service for type`, () => { + const target = new TestHALResource(); + dataService(testType)(target); + expect(getDataServiceFor(testType)).toEqual(target); + }); + }); }); }); diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 9e5ebaed854..be3ffc0f4d7 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -1,25 +1,34 @@ -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { InjectionToken } from '@angular/core'; +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; +import { CacheableObject } from '../cacheable-object.model'; 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, }); export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', { providedIn: 'root', - factory: () => getLinkDefinitions + factory: () => getLinkDefinitions, }); const resolvedLinkKey = Symbol('resolvedLink'); const resolvedLinkMap = new Map(); const typeMap = new Map(); +const dataServiceMap = new Map(); const linkMap = new Map(); /** @@ -38,6 +47,39 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(getResourceTypeValueFor(type)); } +/** + * A class decorator to indicate that this class is a dataservice + * for a given resource type. + * + * "dataservice" in this context means that it has findByHref and + * findAllByHref methods. + * + * @param resourceType the resource type the class is a dataservice for + */ +export function dataService(resourceType: ResourceType): any { + return (target: any) => { + if (hasNoValue(resourceType)) { + throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); + } + const existingDataservice = dataServiceMap.get(resourceType.value); + + if (hasValue(existingDataservice)) { + throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); + } + + dataServiceMap.set(resourceType.value, target); + }; +} + +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ +export function getDataServiceFor(resourceType: ResourceType) { + return dataServiceMap.get(resourceType.value); +} + /** * A class to represent the data that can be set by the @link decorator */ @@ -65,7 +107,7 @@ export const link = ( resourceType: ResourceType, isList = false, linkName?: keyof T['_links'], - ) => { +) => { return (target: T, propertyName: string) => { let targetMap = linkMap.get(target.constructor); @@ -81,7 +123,7 @@ export const link = ( resourceType, isList, linkName, - propertyName + propertyName, }); linkMap.set(target.constructor, targetMap); diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index 0ddfe058701..d9878913888 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -1,15 +1,21 @@ /* eslint-disable max-classes-per-file */ -import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { + isEmpty, + take, +} from 'rxjs/operators'; + +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; +import { TestDataService } from '../../../shared/testing/test-data-service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; +import { + LINK_DEFINITION_FACTORY, + LINK_DEFINITION_MAP_FACTORY, +} from './build-decorators'; import { LinkService } from './link.service'; -import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; -import { isEmpty } from 'rxjs/operators'; -import { FindListOptions } from '../../data/find-list-options.model'; -import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; const TEST_MODEL = new ResourceType('testmodel'); let result: any; @@ -31,16 +37,9 @@ class TestModel implements HALResource { successor?: TestModel; } -@Injectable() -class TestDataService { - findListByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { - return 'findListByHref'; - } - - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { - return 'findByHref'; - } -} +const mockDataServiceMap: any = new Map([ + [TEST_MODEL.value, () => import('../../../shared/testing/test-data-service.mock').then(m => m.TestDataService)], +]); let testDataService: TestDataService; @@ -54,48 +53,54 @@ describe('LinkService', () => { value: 'a test value', _links: { self: { - href: 'http://self.link' + href: 'http://self.link', }, predecessor: { - href: 'http://predecessor.link' + href: 'http://predecessor.link', }, successor: { - href: 'http://successor.link' + href: 'http://successor.link', }, - } + }, }); testDataService = new TestDataService(); spyOn(testDataService, 'findListByHref').and.callThrough(); spyOn(testDataService, 'findByHref').and.callThrough(); TestBed.configureTestingModule({ - providers: [LinkService, { - provide: TestDataService, - useValue: testDataService - }, { - provide: DATA_SERVICE_FACTORY, - useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService), - }, { - provide: LINK_DEFINITION_FACTORY, - useValue: jasmine.createSpy('getLinkDefinition').and.returnValue({ - resourceType: TEST_MODEL, - linkName: 'predecessor', - propertyName: 'predecessor' - }), - }, { - provide: LINK_DEFINITION_MAP_FACTORY, - useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue([ - { + providers: [ + LinkService, + { + provide: TestDataService, + useValue: testDataService, + }, + { + provide: APP_DATA_SERVICES_MAP, + useValue: mockDataServiceMap, + }, + { + provide: LINK_DEFINITION_FACTORY, + useValue: jasmine.createSpy('getLinkDefinition').and.returnValue({ resourceType: TEST_MODEL, linkName: 'predecessor', propertyName: 'predecessor', - }, - { - resourceType: TEST_MODEL, - linkName: 'successor', - propertyName: 'successor', - } - ]), - }] + }), + }, + { + provide: LINK_DEFINITION_MAP_FACTORY, + useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue([ + { + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor', + }, + { + resourceType: TEST_MODEL, + linkName: 'successor', + propertyName: 'successor', + }, + ]), + }, + ], }); service = TestBed.inject(LinkService); }); @@ -103,10 +108,13 @@ describe('LinkService', () => { describe('resolveLink', () => { describe(`when the linkdefinition concerns a single object`, () => { beforeEach(() => { - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); + result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); }); - it('should call dataservice.findByHref with the correct href and nested links', () => { - expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); + it('should call dataservice.findByHref with the correct href and nested links', (done) => { + result.predecessor.pipe(take(1)).subscribe(() => { + expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); + done(); + }); }); }); describe(`when the linkdefinition concerns a list`, () => { @@ -115,12 +123,15 @@ describe('LinkService', () => { resourceType: TEST_MODEL, linkName: 'predecessor', propertyName: 'predecessor', - isList: true + isList: true, }); - service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); + result = service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); }); - it('should call dataservice.findListByHref with the correct href, findListOptions, and nested links', () => { - expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); + it('should call dataservice.findListByHref with the correct href, findListOptions, and nested links', (done) => { + result.predecessor.pipe(take(1)).subscribe((res) => { + expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); + done(); + }); }); }); describe('either way', () => { @@ -132,15 +143,14 @@ describe('LinkService', () => { expect((service as any).getLinkDefinition).toHaveBeenCalledWith(testModel.constructor as any, 'predecessor'); }); - it('should call getDataServiceFor with the correct resource type', () => { - expect((service as any).getDataServiceFor).toHaveBeenCalledWith(TEST_MODEL); - }); - - it('should return the model with the resolved link', () => { + it('should return the model with the resolved link', (done) => { expect(result.type).toBe(TEST_MODEL); expect(result.value).toBe('a test value'); expect(result._links.self.href).toBe('http://self.link'); - expect(result.predecessor).toBe('findByHref'); + result.predecessor.subscribe((res) => { + expect(res).toBe('findByHref'); + done(); + }); }); }); @@ -157,12 +167,16 @@ describe('LinkService', () => { describe(`when there is no dataservice for the resourcetype in the link`, () => { beforeEach(() => { - ((service as any).getDataServiceFor as jasmine.Spy).and.returnValue(undefined); + (service as any).map = {}; }); - it('should throw an error', () => { - expect(() => { - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); - }).toThrow(); + it('should throw an error', (done) => { + result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); + result.predecessor.subscribe({ + error: (error: unknown) => { + expect(error).toBeDefined(); + done(); + }, + }); }); }); }); @@ -213,12 +227,12 @@ describe('LinkService', () => { value: 'a test value', _links: { self: { - href: 'http://self.link' + href: 'http://self.link', }, predecessor: { - href: 'http://predecessor.link' - } - } + href: 'http://predecessor.link', + }, + }, }); }); @@ -227,8 +241,11 @@ describe('LinkService', () => { result = service.resolveLinks(testModel, followLink('predecessor')); }); - it('should return the model with the resolved link', () => { - expect(result.predecessor).toBe('findByHref'); + it('should return the model with the resolved link', (done) => { + result.predecessor.subscribe((res) => { + expect(res).toBe('findByHref'); + done(); + }); }); }); @@ -237,7 +254,7 @@ describe('LinkService', () => { ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({ resourceType: TEST_MODEL, linkName: 'successor', - propertyName: 'successor' + propertyName: 'successor', }); result = service.resolveLinks(testModel, followLink('successor')); }); diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index afc7ab88e40..6265e89d532 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -1,32 +1,48 @@ -import { Inject, Injectable, Injector } from '@angular/core'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { + Inject, + Injectable, + Injector, +} from '@angular/core'; +import { + EMPTY, + Observable, +} from 'rxjs'; +import { + catchError, + switchMap, +} from 'rxjs/operators'; + +import { + APP_DATA_SERVICES_MAP, + LazyDataServicesMap, +} from '../../../../config/app-config.interface'; +import { + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { HALDataService } from '../../data/base/hal-data-service.interface'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { lazyDataService } from '../../lazy-data-service'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; -import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY, LinkDefinition, } from './build-decorators'; -import { RemoteData } from '../../data/remote-data'; -import { EMPTY, Observable } from 'rxjs'; -import { ResourceType } from '../../shared/resource-type'; -import { HALDataService } from '../../data/base/hal-data-service.interface'; -import { PaginatedList } from '../../data/paginated-list.model'; /** * A Service to handle the resolving and removing * of resolved {@link HALLink}s on HALResources */ -@Injectable({ - providedIn: 'root', -}) +@Injectable({ providedIn: 'root' }) export class LinkService { constructor( - protected parentInjector: Injector, - @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>, + protected injector: Injector, + @Inject(APP_DATA_SERVICES_MAP) private map: LazyDataServicesMap, @Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: (source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition, @Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: (source: GenericConstructor) => Map>, ) { @@ -55,34 +71,32 @@ export class LinkService { */ public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable>> { const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); - if (hasValue(matchingLinkDef)) { - const provider = this.getDataServiceFor(matchingLinkDef.resourceType); + const lazyProvider$: Observable> = lazyDataService(this.map, matchingLinkDef.resourceType.value, this.injector); + return lazyProvider$.pipe( + switchMap((provider: HALDataService) => { + const link = model._links[matchingLinkDef.linkName]; + if (hasValue(link)) { + const href = link.href; - if (hasNoValue(provider)) { - throw new Error(`The @link() for ${String(linkToFollow.name)} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); - } - - const service: HALDataService = Injector.create({ - providers: [], - parent: this.parentInjector, - }).get(provider); - - const link = model._links[matchingLinkDef.linkName]; - if (hasValue(link)) { - const href = link.href; - - try { - if (matchingLinkDef.isList) { - return service.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); - } else { - return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + try { + if (matchingLinkDef.isList) { + return provider.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + } else { + return provider.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + } + } catch (e) { + console.error(`Something went wrong when using ${matchingLinkDef.resourceType.value}) ${hasValue(provider) ? '' : '(undefined) '}to resolve link ${String(linkToFollow.name)} at ${href}`); + throw e; + } } - } catch (e) { - console.error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${String(linkToFollow.name)} at ${href}`); - throw e; - } - } + + return EMPTY; + }), + catchError((err: unknown) => { + throw new Error(`The @link() for ${String(linkToFollow.name)} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); + }), + ); } else if (!linkToFollow.isOptional) { throw new Error(`followLink('${String(linkToFollow.name)}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${String(linkToFollow.name)}`); } diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index d9b856bb777..ec756ce85eb 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -1,26 +1,43 @@ -import { createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { buildPaginatedList, PaginatedList } from '../../data/paginated-list.model'; -import { Item } from '../../shared/item.model'; -import { PageInfo } from '../../shared/page-info.model'; -import { RemoteDataBuildService } from './remote-data-build.service'; -import { ObjectCacheService } from '../object-cache.service'; -import { ITEM } from '../../shared/item.resource-type'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { take } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; + import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; -import { LinkService } from './link.service'; -import { RequestService } from '../../data/request.service'; -import { UnCacheableObject } from '../../shared/uncacheable-object.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { + createFailedRemoteDataObject, + createPendingRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../../../shared/remote-data.utils'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; -import { Observable, of as observableOf } from 'rxjs'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { take } from 'rxjs/operators'; -import { HALLink } from '../../shared/hal-link.model'; -import { RequestEntryState } from '../../data/request-entry-state.model'; +import { RequestService } from '../../data/request.service'; import { RequestEntry } from '../../data/request-entry.model'; -import { cold } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { fakeAsync, tick } from '@angular/core/testing'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { Item } from '../../shared/item.model'; +import { ITEM } from '../../shared/item.resource-type'; +import { PageInfo } from '../../shared/page-info.model'; +import { UnCacheableObject } from '../../shared/uncacheable-object.model'; +import { ObjectCacheService } from '../object-cache.service'; +import { LinkService } from './link.service'; +import { RemoteDataBuildService } from './remote-data-build.service'; describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; @@ -49,7 +66,7 @@ describe('RemoteDataBuildService', () => { linkService = getMockLinkService(); requestService = getMockRequestService(); unCacheableObject = { - foo: 'bar' + foo: 'bar', }; pageInfo = new PageInfo(); selfLink1 = 'https://rest.api/some/object'; @@ -64,31 +81,31 @@ describe('RemoteDataBuildService', () => { 'dc.title': [ { language: 'en_US', - value: 'Item nr 1' - } - ] + value: 'Item nr 1', + }, + ], }, _links: { self: { - href: selfLink1 - } - } + href: selfLink1, + }, + }, }), Object.assign(new Item(), { metadata: { 'dc.title': [ { language: 'en_US', - value: 'Item nr 2' - } - ] + value: 'Item nr 2', + }, + ], }, _links: { self: { - href: selfLink2 - } - } - }) + href: selfLink2, + }, + }, + }), ]; paginatedList = buildPaginatedList(pageInfo, array); normalizedPaginatedList = buildPaginatedList(pageInfo, array, true); @@ -96,43 +113,43 @@ describe('RemoteDataBuildService', () => { paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); entrySuccessCacheable = { request: { - uuid: '17820127-0ee5-4ed4-b6da-e654bdff8487' + uuid: '17820127-0ee5-4ed4-b6da-e654bdff8487', }, state: RequestEntryState.Success, response: { statusCode: 200, payloadLink: { - href: selfLink1 - } - } + href: selfLink1, + }, + }, } as RequestEntry; entrySuccessUnCacheable = { request: { - uuid: '0aa5ec06-d6a7-4e73-952e-1e0462bd1501' + uuid: '0aa5ec06-d6a7-4e73-952e-1e0462bd1501', }, state: RequestEntryState.Success, response: { statusCode: 200, unCacheableObject, - } + }, } as RequestEntry; entrySuccessNoContent = { request: { - uuid: '780a7295-6102-4a43-9775-80f2a4ff673c' + uuid: '780a7295-6102-4a43-9775-80f2a4ff673c', }, state: RequestEntryState.Success, response: { - statusCode: 204 + statusCode: 204, }, } as RequestEntry; entryError = { request: { - uuid: '1609dcbc-8442-4877-966e-864f151cc40c' + uuid: '1609dcbc-8442-4877-966e-864f151cc40c', }, state: RequestEntryState.Error, response: { statusCode: 500, - } + }, } as RequestEntry; requestEntry$ = observableOf(entrySuccessCacheable); linksToFollow = [ @@ -427,8 +444,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - payloadLink: { href: 'payload-link' } - } + payloadLink: { href: 'payload-link' }, + }, }; }); @@ -441,8 +458,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - payloadLink: undefined - } + payloadLink: undefined, + }, }; }); @@ -459,8 +476,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - unCacheableObject: Object.assign({}) - } + unCacheableObject: Object.assign({}), + }, }; }); @@ -472,7 +489,7 @@ describe('RemoteDataBuildService', () => { describe('when the entry\'s response doesn\'t contain an uncacheable object', () => { beforeEach(() => { entry = { - response: {} + response: {}, }; }); @@ -487,7 +504,7 @@ describe('RemoteDataBuildService', () => { it(`should return a new instance of that type`, () => { const source: any = { type: ITEM, - uuid: 'some-uuid' + uuid: 'some-uuid', }; const result = (service as any).plainObjectToInstance(source); @@ -503,7 +520,7 @@ describe('RemoteDataBuildService', () => { it(`should return a new plain JS object`, () => { const source: any = { type: 'foobar', - uuid: 'some-uuid' + uuid: 'some-uuid', }; const result = (service as any).plainObjectToInstance(source); @@ -528,7 +545,7 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { paginatedLinksToFollow = [ followLink('page', {}, ...linksToFollow), - ...linksToFollow + ...linksToFollow, ]; }); describe(`and the given list doesn't have a page property already`, () => { @@ -843,15 +860,15 @@ describe('RemoteDataBuildService', () => { it('should only emit after the callback is done', () => { testScheduler.run(({ cold: tsCold, expectObservable }) => { buildFromRequestUUIDSpy.and.returnValue( - tsCold('-p----s', RDs) + tsCold('-p----s', RDs), ); callback.and.returnValue( - tsCold(' --t', BOOLEAN) + tsCold(' --t', BOOLEAN), ); const done$ = service.buildFromRequestUUIDAndAwait('some-href', callback); expectObservable(done$).toBe( - ' -p------s', RDs // resulting duration between pending & successful includes the callback + ' -p------s', RDs, // resulting duration between pending & successful includes the callback ); }); }); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 075bf3ca0ca..36305b4a0c4 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -5,29 +5,52 @@ import { Observable, of as observableOf, } from 'rxjs'; -import { map, switchMap, filter, distinctUntilKeyChanged, startWith } from 'rxjs/operators'; -import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util'; +import { + distinctUntilKeyChanged, + filter, + map, + startWith, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, + isUndefined, +} from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list.model'; +import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; import { RemoteData } from '../../data/remote-data'; import { RequestService } from '../../data/request.service'; -import { ObjectCacheService } from '../object-cache.service'; -import { LinkService } from './link.service'; -import { HALLink } from '../../shared/hal-link.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { getClassForType } from './build-decorators'; -import { HALResource } from '../../shared/hal-resource.model'; -import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; -import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; -import { getResourceTypeValueFor } from '../object-cache.reducer'; -import { hasSucceeded, isStale, RequestEntryState } from '../../data/request-entry-state.model'; -import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/request.operators'; import { RequestEntry } from '../../data/request-entry.model'; +import { + hasSucceeded, + isStale, + RequestEntryState, +} from '../../data/request-entry-state.model'; import { ResponseState } from '../../data/response-state.model'; +import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { HALLink } from '../../shared/hal-link.model'; +import { HALResource } from '../../shared/hal-resource.model'; import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { + getRequestFromRequestHref, + getRequestFromRequestUUID, +} from '../../shared/request.operators'; +import { getResourceTypeValueFor } from '../object-cache.reducer'; +import { ObjectCacheService } from '../object-cache.service'; +import { getClassForType } from './build-decorators'; +import { LinkService } from './link.service'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class RemoteDataBuildService { constructor(protected objectCache: ObjectCacheService, protected linkService: LinkService, @@ -77,7 +100,7 @@ export class RemoteDataBuildService { } } return [obj]; - }) + }), ); } @@ -151,7 +174,7 @@ export class RemoteDataBuildService { paginatedList.page = page .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow), ); if (isNotEmpty(otherLinks)) { return this.linkService.resolveLinks(paginatedList, ...otherLinks); @@ -161,9 +184,10 @@ export class RemoteDataBuildService { } else { // in case the elements of the paginated list were already filled in, because they're UnCacheableObjects paginatedList.page = paginatedList.page + .filter((obj: any) => obj != null) .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow), ); if (isNotEmpty(otherLinks)) { return observableOf(this.linkService.resolveLinks(paginatedList, ...otherLinks)); @@ -229,7 +253,7 @@ export class RemoteDataBuildService { } else { return [rd]; } - }) + }), ); } @@ -272,12 +296,13 @@ export class RemoteDataBuildService { return isStale(r2.state) ? r1 : r2; } }), - distinctUntilKeyChanged('lastUpdated') ); const payload$ = this.buildPayload(requestEntry$, href$, ...linksToFollow); - return this.toRemoteDataObservable(requestEntry$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$).pipe( + distinctUntilKeyChanged('lastUpdated'), + ); } /** @@ -293,12 +318,12 @@ export class RemoteDataBuildService { toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { return observableCombineLatest([ requestEntry$, - payload$ + payload$, ]).pipe( filter(([entry,payload]: [RequestEntry, T]) => hasValue(entry) && // filter out cases where the state is successful, but the payload isn't yet set - !(hasSucceeded(entry.state) && isUndefined(payload)) + !(hasSucceeded(entry.state) && isUndefined(payload)), ), map(([entry, payload]: [RequestEntry, T]) => { let response = entry.response; @@ -313,9 +338,9 @@ export class RemoteDataBuildService { entry.state, response.errorMessage, payload, - response.statusCode + response.statusCode, ); - }) + }), ); } @@ -406,7 +431,7 @@ export class RemoteDataBuildService { state, errorMessage, payload, - statusCode + statusCode, ); })); } diff --git a/src/app/core/cache/cacheable-object.model.ts b/src/app/core/cache/cacheable-object.model.ts index b7d1609d585..86d041dab77 100644 --- a/src/app/core/cache/cacheable-object.model.ts +++ b/src/app/core/cache/cacheable-object.model.ts @@ -1,6 +1,6 @@ /* tslint:disable:max-classes-per-file */ -import { HALResource } from '../shared/hal-resource.model'; import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; import { TypedObject } from './typed-object.model'; /** diff --git a/src/app/core/cache/models/request-param.model.ts b/src/app/core/cache/models/request-param.model.ts index ac21fe0b8a8..b78fa45e337 100644 --- a/src/app/core/cache/models/request-param.model.ts +++ b/src/app/core/cache/models/request-param.model.ts @@ -1,9 +1,14 @@ - /** * Class representing a query parameter (query?fieldName=fieldValue) used in FindListOptions object */ export class RequestParam { - constructor(public fieldName: string, public fieldValue: any) { - + constructor( + public fieldName: string, + public fieldValue: any, + public encodeValue = true, + ) { + if (encodeValue) { + this.fieldValue = encodeURIComponent(fieldValue); + } } } diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index c18a20ffd63..5f8f60e1f1f 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -1,8 +1,8 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { type } from '../../shared/ngrx/type'; -import { Operation } from 'fast-json-patch'; import { CacheableObject } from './cacheable-object.model'; /** @@ -15,7 +15,7 @@ export const ObjectCacheActionTypes = { ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH'), ADD_DEPENDENTS: type('dspace/core/cache/object/ADD_DEPENDENTS'), - REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS') + REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS'), }; /** diff --git a/src/app/core/cache/object-cache.effects.spec.ts b/src/app/core/cache/object-cache.effects.spec.ts index 3a50a5dbc74..66270be4c26 100644 --- a/src/app/core/cache/object-cache.effects.spec.ts +++ b/src/app/core/cache/object-cache.effects.spec.ts @@ -1,10 +1,14 @@ import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { ObjectCacheEffects } from './object-cache.effects'; -import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + import { StoreActionTypes } from '../../store.actions'; +import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { ObjectCacheEffects } from './object-cache.effects'; describe('ObjectCacheEffects', () => { let cacheEffects: ObjectCacheEffects; diff --git a/src/app/core/cache/object-cache.effects.ts b/src/app/core/cache/object-cache.effects.ts index fa2bf6f6902..0de59a152cb 100644 --- a/src/app/core/cache/object-cache.effects.ts +++ b/src/app/core/cache/object-cache.effects.ts @@ -1,6 +1,10 @@ -import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { map } from 'rxjs/operators'; import { StoreActionTypes } from '../../store.actions'; import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; @@ -16,9 +20,9 @@ export class ObjectCacheEffects { * This assumes that the server cached everything a negligible * time ago, and will likely need to be revisited later */ - fixTimestampsOnRehydrate = createEffect(() => this.actions$ + fixTimestampsOnRehydrate = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), - map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())) + map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())), )); constructor(private actions$: Actions) { diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 919edc8e577..7dda02a0f5d 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; import { Operation } from 'fast-json-patch'; + import { Item } from '../shared/item.model'; import { AddDependentsObjectCacheAction, @@ -11,7 +12,6 @@ import { RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, } from './object-cache.actions'; - import { objectCacheReducer } from './object-cache.reducer'; class NullAction extends RemoveFromObjectCacheAction { @@ -54,7 +54,7 @@ describe('objectCacheReducer', () => { type: Item.type, self: selfLink2, foo: 'baz', - _links: { self: { href: selfLink2 } } + _links: { self: { href: selfLink2 } }, }, alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), @@ -62,8 +62,8 @@ describe('objectCacheReducer', () => { requestUUIDs: [requestUUID2], dependentRequestUUIDs: [requestUUID1], patches: [], - isDirty: false - } + isDirty: false, + }, }; deepFreeze(testState); @@ -126,6 +126,10 @@ describe('objectCacheReducer', () => { deepFreeze(state); objectCacheReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should remove the specified object from the cache in response to the REMOVE action', () => { @@ -149,6 +153,10 @@ describe('objectCacheReducer', () => { const action = new RemoveFromObjectCacheAction(selfLink1); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should set the timestamp of all objects in the cache in response to a RESET_TIMESTAMPS action', () => { @@ -164,16 +172,24 @@ describe('objectCacheReducer', () => { const action = new ResetObjectCacheTimestampsAction(new Date().getTime()); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_PATCH action without affecting the previous state', () => { const action = new AddPatchObjectCacheAction(selfLink1, [{ op: 'replace', path: '/name', - value: 'random string' + value: 'random string', }]); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should when the ADD_PATCH action dispatched', () => { diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index dc3f50db68f..553fd9b10af 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,18 +1,26 @@ /* eslint-disable max-classes-per-file */ +import { + applyPatch, + Operation, +} from 'fast-json-patch'; + +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { CacheEntry } from './cache-entry'; +import { CacheableObject } from './cacheable-object.model'; import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, ObjectCacheAction, - ObjectCacheActionTypes, RemoveDependentsObjectCacheAction, + ObjectCacheActionTypes, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, } from './object-cache.actions'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { CacheEntry } from './cache-entry'; -import { applyPatch, Operation } from 'fast-json-patch'; -import { CacheableObject } from './cacheable-object.model'; /** * An interface to represent a JsonPatch @@ -166,20 +174,25 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi * the new state, with the object added, or overwritten. */ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { - const existing = state[action.payload.objectToCache._links.self.href] || {} as any; + const cacheLink = hasValue(action.payload.objectToCache?._links?.self) ? action.payload.objectToCache._links.self.href : action.payload.alternativeLink; + const existing = state[cacheLink] || {} as any; const newAltLinks = hasValue(action.payload.alternativeLink) ? [action.payload.alternativeLink] : []; - return Object.assign({}, state, { - [action.payload.objectToCache._links.self.href]: { - data: action.payload.objectToCache, - timeCompleted: action.payload.timeCompleted, - msToLive: action.payload.msToLive, - requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], - dependentRequestUUIDs: existing.dependentRequestUUIDs || [], - isDirty: isNotEmpty(existing.patches), - patches: existing.patches || [], - alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] - } as ObjectCacheEntry - }); + if (hasValue(cacheLink)) { + return Object.assign({}, state, { + [cacheLink]: { + data: action.payload.objectToCache, + timeCompleted: action.payload.timeCompleted, + msToLive: action.payload.msToLive, + requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], + dependentRequestUUIDs: existing.dependentRequestUUIDs || [], + isDirty: isNotEmpty(existing.patches), + patches: existing.patches || [], + alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks], + } as ObjectCacheEntry, + }); + } else { + return state; + } } /** @@ -217,7 +230,7 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject const newState = Object.create(null); Object.keys(state).forEach((key) => { newState[key] = Object.assign({}, state[key], { - timeCompleted: action.payload + timeCompleted: action.payload, }); }); return newState; @@ -241,7 +254,7 @@ function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCach const patches = newState[uuid].patches; newState[uuid] = Object.assign({}, newState[uuid], { patches: [...patches, { operations } as Patch], - isDirty: true + isDirty: true, }); } return newState; @@ -286,8 +299,8 @@ function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDepen ...new Set([ ...newState[href]?.dependentRequestUUIDs || [], ...action.payload.dependentRequestUUIDs, - ]) - ] + ]), + ], }); } @@ -308,7 +321,7 @@ function removeDependentsObjectCacheState(state: ObjectCacheState, action: Remov if (hasValue(newState[href])) { newState[href] = Object.assign({}, newState[href], { - dependentRequestUUIDs: [] + dependentRequestUUIDs: [], }); } diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 6af797be299..3d27f7252c5 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,33 +1,44 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { cold } from 'jasmine-marbles'; -import { Store, StoreModule } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { Operation } from 'fast-json-patch'; -import { empty, of as observableOf } from 'rxjs'; +import { cold } from 'jasmine-marbles'; +import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; +import { + empty, + of as observableOf, +} from 'rxjs'; import { first } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; -import { coreReducers} from '../core.reducers'; +import { storeModuleConfig } from '../../app.reducer'; +import { coreReducers } from '../core.reducers'; +import { CoreState } from '../core-state.model'; import { RestRequestMethod } from '../data/rest-request-method'; +import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { IndexName } from '../index/index-name.model'; +import { HALLink } from '../shared/hal-link.model'; import { Item } from '../shared/item.model'; import { AddDependentsObjectCacheAction, - RemoveDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, } from './object-cache.actions'; import { Patch } from './object-cache.reducer'; import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { HALLink } from '../shared/hal-link.model'; -import { storeModuleConfig } from '../../app.reducer'; -import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; -import { IndexName } from '../index/index-name.model'; -import { CoreState } from '../core-state.model'; -import { TestScheduler } from 'rxjs/testing'; describe('ObjectCacheService', () => { let service: ObjectCacheService; @@ -69,8 +80,8 @@ describe('ObjectCacheService', () => { type: Item.type, _links: { self: { href: selfLink }, - anotherLink: { href: anotherLink } - } + anotherLink: { href: anotherLink }, + }, }; cacheEntry = { data: objectToCache, @@ -96,8 +107,8 @@ describe('ObjectCacheService', () => { 'cache/syncbuffer': {}, 'cache/object-updates': {}, 'data/request': {}, - 'index': {} - } + 'index': {}, + }, }; } @@ -105,12 +116,12 @@ describe('ObjectCacheService', () => { TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot(coreReducers, storeModuleConfig) + StoreModule.forRoot(coreReducers, storeModuleConfig), ], providers: [ provideMockStore({ initialState }), - { provide: ObjectCacheService, useValue: service } - ] + { provide: ObjectCacheService, useValue: service }, + ], }).compileComponents(); })); @@ -120,7 +131,7 @@ describe('ObjectCacheService', () => { mockStore = store as MockStore; mockStore.setState(initialState); linkServiceStub = { - removeResolvedLinks: (a) => a + removeResolvedLinks: (a) => a, }; spyOn(linkServiceStub, 'removeResolvedLinks').and.callThrough(); spyOn(store, 'dispatch'); @@ -209,7 +220,7 @@ describe('ObjectCacheService', () => { describe('getList', () => { it('should return an observable of the array of cached objects with the specified self link and type', () => { const item = Object.assign(new Item(), { - _links: { self: { href: selfLink } } + _links: { self: { href: selfLink } }, }); spyOn(service, 'getObjectByHref').and.returnValue(observableOf(item)); @@ -251,7 +262,7 @@ describe('ObjectCacheService', () => { 'something', 'something-else', 'specific-request', - ] + ], }))); }); @@ -266,7 +277,7 @@ describe('ObjectCacheService', () => { requestUUIDs: [ 'something', 'something-else', - ] + ], }))); }); @@ -292,9 +303,9 @@ describe('ObjectCacheService', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'cache/object': { - [selfLink]: cacheEntry - } - }) + [selfLink]: cacheEntry, + }, + }), }); mockStore.setState(state); const expected: TestColdObservable = cold('a', { a: cacheEntry }); @@ -310,14 +321,14 @@ describe('ObjectCacheService', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'cache/object': { - [selfLink]: cacheEntry + [selfLink]: cacheEntry, }, 'index': { 'object/alt-link-to-self-link': { - [anotherLink]: selfLink - } - } - }) + [anotherLink]: selfLink, + }, + }, + }), }); mockStore.setState(state); (service as any).getByAlternativeLink(anotherLink).subscribe(); @@ -335,8 +346,8 @@ describe('ObjectCacheService', () => { it('isDirty should return true when the patches list in the cache entry is not empty', () => { cacheEntry.patches = [ { - operations: operations - } as Patch + operations: operations, + } as Patch, ]; const result = (service as any).isDirty(cacheEntry); expect(result).toBe(true); @@ -371,9 +382,9 @@ describe('ObjectCacheService', () => { [anotherLink]: selfLink, ['objectWithoutDependentsAlt']: 'objectWithoutDependents', ['objectWithDependentsAlt']: 'objectWithDependents', - } - } - }) + }, + }, + }), }); mockStore.setState(state); }); @@ -421,11 +432,11 @@ describe('ObjectCacheService', () => { testScheduler.run(({ cold: tsCold, flush }) => { const href$ = tsCold('--y-n-n', { y: selfLink, - n: 'NOPE' + n: 'NOPE', }); const dependsOnHref$ = tsCold('-y-n-n', { y: 'objectWithoutDependents', - n: 'NOPE' + n: 'NOPE', }); service.addDependency(href$, dependsOnHref$); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 9ca02162108..f645b5a878d 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,32 +1,68 @@ import { Injectable } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { applyPatch, Operation } from 'fast-json-patch'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; - -import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core-state.model'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { + applyPatch, + Operation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + mergeMap, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; import { RestRequestMethod } from '../data/rest-request-method'; -import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; +import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { + selfLinkFromAlternativeLinkSelector, + selfLinkFromUuidSelector, +} from '../index/index.selectors'; +import { IndexName } from '../index/index-name.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; -import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; - -import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; -import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { HALLink } from '../shared/hal-link.model'; import { CacheableObject } from './cacheable-object.model'; -import { IndexName } from '../index/index-name.model'; +import { + AddDependentsObjectCacheAction, + AddPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, + RemoveFromObjectCacheAction, +} from './object-cache.actions'; +import { + ObjectCacheEntry, + ObjectCacheState, +} from './object-cache.reducer'; +import { AddToSSBAction } from './server-sync-buffer.actions'; /** * The base selector function to select the object cache in the store */ const objectCacheSelector = createSelector( coreSelector, - (state: CoreState) => state['cache/object'] + (state: CoreState) => state['cache/object'], ); /** @@ -42,11 +78,11 @@ const entryFromSelfLinkSelector = /** * A service to interact with the object cache */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ObjectCacheService { constructor( private store: Store, - private linkService: LinkService + private linkService: LinkService, ) { } @@ -63,7 +99,9 @@ export class ObjectCacheService { * An optional alternative link to this object */ add(object: CacheableObject, msToLive: number, requestUUID: string, alternativeLink?: string): void { - object = this.linkService.removeResolvedLinks(object); // Ensure the object we're storing has no resolved links + if (hasValue(object)) { + object = this.linkService.removeResolvedLinks(object); // Ensure the object we're storing has no resolved links + } this.store.dispatch(new AddToObjectCacheAction(object, new Date().getTime(), msToLive, requestUUID, alternativeLink)); } @@ -82,12 +120,12 @@ export class ObjectCacheService { const cacheEntry$ = this.getByHref(href); const altLinks$ = cacheEntry$.pipe(map((entry: ObjectCacheEntry) => entry.alternativeLinks), take(1)); const childLinks$ = cacheEntry$.pipe(map((entry: ObjectCacheEntry) => { - return Object - .entries(entry.data._links) - .filter(([key, value]: [string, HALLink]) => key !== 'self') - .map(([key, value]: [string, HALLink]) => value.href); - }), - take(1) + return Object + .entries(entry.data._links) + .filter(([key, value]: [string, HALLink]) => key !== 'self') + .map(([key, value]: [string, HALLink]) => value.href); + }), + take(1), ); this.removeLinksFromAlternativeLinkIndex(altLinks$); this.removeLinksFromAlternativeLinkIndex(childLinks$); @@ -96,8 +134,8 @@ export class ObjectCacheService { private removeLinksFromAlternativeLinkIndex(links$: Observable) { links$.subscribe((links: string[]) => links.forEach((link: string) => { - this.store.dispatch(new RemoveFromIndexBySubstringAction(IndexName.ALTERNATIVE_OBJECT_LINK, link)); - } + this.store.dispatch(new RemoveFromIndexBySubstringAction(IndexName.ALTERNATIVE_OBJECT_LINK, link)); + }, )); } @@ -113,8 +151,8 @@ export class ObjectCacheService { Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getObjectByHref(selfLink) - ) + mergeMap((selfLink: string) => this.getObjectByHref(selfLink), + ), ); } @@ -129,22 +167,26 @@ export class ObjectCacheService { getObjectByHref(href: string): Observable { return this.getByHref(href).pipe( map((entry: ObjectCacheEntry) => { - if (isNotEmpty(entry.patches)) { - const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); - const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; - return Object.assign({}, entry, { data: patchedData }); - } else { - return entry; - } + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; + return Object.assign({}, entry, { data: patchedData }); + } else { + return entry; } + }, ), map((entry: ObjectCacheEntry) => { - const type: GenericConstructor = getClassForType((entry.data as any).type); - if (typeof type !== 'function') { - throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); + if (hasValue(entry.data)) { + const type: GenericConstructor = getClassForType((entry.data as any).type); + if (typeof type !== 'function') { + throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); + } + return Object.assign(new type(), entry.data) as T; + } else { + return null; } - return Object.assign(new type(), entry.data) as T; - }) + }), ); } @@ -162,13 +204,13 @@ export class ObjectCacheService { this.getBySelfLink(href), ]).pipe( map((results: ObjectCacheEntry[]) => results.find((entry: ObjectCacheEntry) => hasValue(entry))), - filter((entry: ObjectCacheEntry) => hasValue(entry)) + filter((entry: ObjectCacheEntry) => hasValue(entry)), ); } private getBySelfLink(selfLink: string): Observable { return this.store.pipe( - select(entryFromSelfLinkSelector(selfLink)) + select(entryFromSelfLinkSelector(selfLink)), ); } @@ -204,7 +246,7 @@ export class ObjectCacheService { getRequestUUIDByObjectUUID(uuid: string): Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)) + mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)), ); } @@ -232,7 +274,7 @@ export class ObjectCacheService { return observableOf([]); } else { return observableCombineLatest( - selfLinks.map((selfLink: string) => this.getObjectByHref(selfLink)) + selfLinks.map((selfLink: string) => this.getObjectByHref(selfLink)), ); } } @@ -252,7 +294,7 @@ export class ObjectCacheService { /* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/ this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - take(1) + take(1), ).subscribe((selfLink: string) => result = this.hasByHref(selfLink)); return result; @@ -290,9 +332,9 @@ export class ObjectCacheService { hasByHref$(href: string): Observable { return observableCombineLatest( this.getBySelfLink(href), - this.getByAlternativeLink(href) + this.getByAlternativeLink(href), ).pipe( - map((entries: ObjectCacheEntry[]) => entries.some((entry) => hasValue(entry))) + map((entries: ObjectCacheEntry[]) => entries.some((entry) => hasValue(entry))), ); } @@ -358,7 +400,7 @@ export class ObjectCacheService { observableCombineLatest([ href$, dependsOnHref$.pipe( - switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)) + switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)), ), ]).pipe( switchMap(([href, dependsOnSelfLink]: [string, string]) => { @@ -373,7 +415,7 @@ export class ObjectCacheService { this.getByHref(href).pipe( // only add the latest request to keep dependency index from growing indefinitely map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]), - ) + ), ]); }), take(1), diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 197bf130fb2..9a09a49bc81 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,10 +1,10 @@ /* eslint-disable max-classes-per-file */ -import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; +import { RequestError } from '../data/request-error.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALLink } from '../shared/hal-link.model'; +import { PageInfo } from '../shared/page-info.model'; import { UnCacheableObject } from '../shared/uncacheable-object.model'; -import { RequestError } from '../data/request-error.model'; export class RestResponse { public toCache = true; @@ -13,7 +13,7 @@ export class RestResponse { constructor( public isSuccessful: boolean, public statusCode: number, - public statusText: string + public statusText: string, ) { } } @@ -29,7 +29,7 @@ export class DSOSuccessResponse extends RestResponse { public resourceSelfLinks: string[], public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -43,7 +43,7 @@ export class EndpointMapSuccessResponse extends RestResponse { constructor( public endpointMap: EndpointMap, public statusCode: number, - public statusText: string + public statusText: string, ) { super(true, statusCode, statusText); } @@ -64,7 +64,7 @@ export class ConfigSuccessResponse extends RestResponse { public configDefinition: ConfigObject, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -78,7 +78,7 @@ export class TokenResponse extends RestResponse { public token: string, public isSuccessful: boolean, public statusCode: number, - public statusText: string + public statusText: string, ) { super(isSuccessful, statusCode, statusText); } @@ -89,7 +89,7 @@ export class PostPatchSuccessResponse extends RestResponse { public dataDefinition: any, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -100,7 +100,7 @@ export class EpersonSuccessResponse extends RestResponse { public epersonDefinition: DSpaceObject[], public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -112,7 +112,7 @@ export class MessageResponse extends RestResponse { constructor( public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -124,7 +124,7 @@ export class TaskResponse extends RestResponse { constructor( public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -135,7 +135,7 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { public filterQuery: string, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 833c6b580fd..889b3b7454a 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -1,12 +1,22 @@ import { TestBed } from '@angular/core/testing'; - import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { storeModuleConfig } from '../../app.reducer'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; import { StoreMock } from '../../shared/testing/store.mock'; import { RequestService } from '../data/request.service'; import { RestRequestMethod } from '../data/rest-request-method'; @@ -16,11 +26,9 @@ import { ObjectCacheService } from './object-cache.service'; import { CommitSSBAction, EmptySSBAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; -import { storeModuleConfig } from '../../app.reducer'; -import { NoOpAction } from '../../shared/ngrx/no-op.action'; describe('ServerSyncBufferEffects', () => { let ssbEffects: ServerSyncBufferEffects; @@ -32,9 +40,9 @@ describe('ServerSyncBufferEffects', () => { autoSync: { timePerMethod: {}, - defaultTime: 0 - } - } + defaultTime: 0, + }, + }, }; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; let store; @@ -52,21 +60,21 @@ describe('ServerSyncBufferEffects', () => { provide: ObjectCacheService, useValue: { getObjectBySelfLink: (link) => { const object = Object.assign(new DSpaceObject(), { - _links: { self: { href: link } } + _links: { self: { href: link } }, }); return observableOf(object); }, getByHref: (link) => { const object = Object.assign(new DSpaceObject(), { _links: { - self: { href: link } - } + self: { href: link }, + }, }); return observableOf(object); - } - } + }, + }, }, - { provide: Store, useClass: StoreMock } + { provide: Store, useClass: StoreMock }, // other providers ], }); @@ -88,12 +96,12 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.ADD, - payload: { href: selfLink, method: RestRequestMethod.PUT } - } + payload: { href: selfLink, method: RestRequestMethod.PUT }, + }, }); expectObservable(ssbEffects.setTimeoutForServerSync).toBe('b', { - b: new CommitSSBAction(RestRequestMethod.PUT) + b: new CommitSSBAction(RestRequestMethod.PUT), }); }); }); @@ -108,8 +116,8 @@ describe('ServerSyncBufferEffects', () => { (state as any).core['cache/syncbuffer'] = { buffer: [{ href: selfLink, - method: RestRequestMethod.PATCH - }] + method: RestRequestMethod.PATCH, + }], }; }); }); @@ -117,13 +125,13 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.COMMIT, - payload: RestRequestMethod.PATCH - } + payload: RestRequestMethod.PATCH, + }, }); const expected = cold('(bc)', { b: new ApplyPatchObjectCacheAction(selfLink), - c: new EmptySSBAction(RestRequestMethod.PATCH) + c: new EmptySSBAction(RestRequestMethod.PATCH), }); expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); @@ -136,7 +144,7 @@ describe('ServerSyncBufferEffects', () => { .subscribe((state) => { (state as any).core = Object({}); (state as any).core['cache/syncbuffer'] = { - buffer: [] + buffer: [], }; }); }); @@ -145,8 +153,8 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.COMMIT, - payload: { method: RestRequestMethod.PATCH } - } + payload: { method: RestRequestMethod.PATCH }, + }, }); const expected = cold('b', { b: new NoOpAction() }); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 9571d4af5b4..6f346d5bb3f 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,27 +1,55 @@ -import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + delay, + exhaustMap, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { + hasValue, + isNotEmpty, + isNotUndefined, +} from '../../shared/empty.util'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; +import { PatchRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; +import { ObjectCacheEntry } from './object-cache.reducer'; +import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction, CommitSSBAction, EmptySSBAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; -import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { PatchRequest } from '../data/request.models'; -import { ObjectCacheService } from './object-cache.service'; -import { ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { environment } from '../../../environments/environment'; -import { ObjectCacheEntry } from './object-cache.reducer'; -import { Operation } from 'fast-json-patch'; -import { NoOpAction } from '../../shared/ngrx/no-op.action'; -import { CoreState } from '../core-state.model'; +import { + ServerSyncBufferEntry, + ServerSyncBufferState, +} from './server-sync-buffer.reducer'; @Injectable() export class ServerSyncBufferEffects { @@ -32,7 +60,7 @@ export class ServerSyncBufferEffects { * Then dispatch a CommitSSBAction * When the delay is running, no new AddToSSBActions are processed in this effect */ - setTimeoutForServerSync = createEffect(() => this.actions$ + setTimeoutForServerSync = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.ADD), exhaustMap((action: AddToSSBAction) => { @@ -41,7 +69,7 @@ export class ServerSyncBufferEffects { return observableOf(new CommitSSBAction(action.payload.method)).pipe( delay(timeoutInSeconds * 1000), ); - }) + }), )); /** @@ -50,7 +78,7 @@ export class ServerSyncBufferEffects { * When the list of actions is not empty, also dispatch an EmptySSBAction * When the list is empty dispatch a NO_ACTION placeholder action */ - commitServerSyncBuffer = createEffect(() => this.actions$ + commitServerSyncBuffer = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.COMMIT), switchMap((action: CommitSSBAction) => { @@ -78,14 +106,14 @@ export class ServerSyncBufferEffects { /* Add extra action to array, to make sure the ServerSyncBuffer is emptied afterwards */ if (isNotEmpty(actions) && isNotUndefined(actions[0])) { return observableCombineLatest(...actions).pipe( - switchMap((array) => [...array, new EmptySSBAction(action.payload)]) - ); + switchMap((array) => [...array, new EmptySSBAction(action.payload)]), + ); } else { return observableOf(new NoOpAction()); } - }) + }), ); - }) + }), )); /** @@ -96,7 +124,7 @@ export class ServerSyncBufferEffects { */ private applyPatch(href: string): Observable { const patchObject = this.objectCache.getByHref(href).pipe( - take(1) + take(1), ); return patchObject.pipe( @@ -108,7 +136,7 @@ export class ServerSyncBufferEffects { } } return new ApplyPatchObjectCacheAction(href); - }) + }), ); } diff --git a/src/app/core/cache/server-sync-buffer.reducer.spec.ts b/src/app/core/cache/server-sync-buffer.reducer.spec.ts index 51ba010c1e3..d986581ce2e 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.spec.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -1,9 +1,13 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; + +import { RestRequestMethod } from '../data/rest-request-method'; import { RemoveFromObjectCacheAction } from './object-cache.actions'; +import { + AddToSSBAction, + EmptySSBAction, +} from './server-sync-buffer.actions'; import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { AddToSSBAction, EmptySSBAction } from './server-sync-buffer.actions'; class NullAction extends RemoveFromObjectCacheAction { type = null; @@ -27,8 +31,8 @@ describe('serverSyncBufferReducer', () => { { href: selfLink2, method: RestRequestMethod.GET, - } - ] + }, + ], }; const newSelfLink = 'https://localhost:8080/api/core/items/1ce6b5ae-97e1-4e5a-b4b0-f9029bad10c0'; @@ -52,12 +56,20 @@ describe('serverSyncBufferReducer', () => { const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the EMPTY action without affecting the previous state', () => { const action = new EmptySSBAction(); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => { @@ -79,7 +91,7 @@ describe('serverSyncBufferReducer', () => { // testState has already been frozen above const newState = serverSyncBufferReducer(testState, action); expect(newState.buffer).toContain({ - href: newSelfLink, method: RestRequestMethod.PUT + href: newSelfLink, method: RestRequestMethod.PUT, }) ; }); diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e8944aa731..f1ae8943151 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -1,11 +1,14 @@ -import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; +import { RestRequestMethod } from '../data/rest-request-method'; import { AddToSSBAction, EmptySSBAction, ServerSyncBufferAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; -import { RestRequestMethod } from '../data/rest-request-method'; /** * An entry in the ServerSyncBufferState @@ -86,9 +89,9 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi * the new state, with a new entry added to the buffer */ function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState { - let newBuffer = []; - if (hasValue(action.payload)) { - newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); - } - return Object.assign({}, state, { buffer: newBuffer }); + let newBuffer = []; + if (hasValue(action.payload)) { + newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); + } + return Object.assign({}, state, { buffer: newBuffer }); } diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts new file mode 100644 index 00000000000..706c8f684b9 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts @@ -0,0 +1,40 @@ +import { of } from 'rxjs'; + +import { notifyInfoGuard } from './notify-info.guard'; + +describe('notifyInfoGuard', () => { + let guard: any; + let notifyInfoServiceSpy: any; + let router: any; + + beforeEach(() => { + notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['isCoarConfigEnabled']); + router = jasmine.createSpyObj('Router', ['parseUrl']); + guard = notifyInfoGuard; + }); + + it('should be created', () => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true)); + expect(guard(null, null, notifyInfoServiceSpy, router)).toBeTruthy(); + }); + + it('should return true if COAR config is enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true)); + + guard(null, null, notifyInfoServiceSpy, router).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it('should call parseUrl method of Router if COAR config is not enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(false)); + router.parseUrl.and.returnValue(of('/404')); + + guard(null, null, notifyInfoServiceSpy, router).subscribe(() => { + expect(router.parseUrl).toHaveBeenCalledWith('/404'); + done(); + }); + }); + +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.ts new file mode 100644 index 00000000000..1025e7b62bc --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.ts @@ -0,0 +1,23 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { NotifyInfoService } from './notify-info.service'; + +export const notifyInfoGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + notifyInfoService: NotifyInfoService = inject(NotifyInfoService), + router: Router = inject(Router), +): Observable => { + return notifyInfoService.isCoarConfigEnabled().pipe( + map(isEnabled => isEnabled ? true : router.parseUrl('/404')), + ); +}; diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts new file mode 100644 index 00000000000..6fa8295be06 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { NotifyInfoService } from './notify-info.service'; + +describe('NotifyInfoService', () => { + let service: NotifyInfoService; + let configurationDataService: any; + let authorizationDataService: any; + beforeEach(() => { + authorizationDataService = { + isAuthorized: jasmine.createSpy('isAuthorized').and.returnValue(of(true)), + }; + configurationDataService = { + findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(of({})), + }; + TestBed.configureTestingModule({ + providers: [ + NotifyInfoService, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + ], + }); + service = TestBed.inject(NotifyInfoService); + authorizationDataService = TestBed.inject(AuthorizationDataService); + configurationDataService = TestBed.inject(ConfigurationDataService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should retrieve and map coar configuration', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['true'] })); + + service.isCoarConfigEnabled().subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it('should retrieve and map LDN local inbox URLs', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['inbox1', 'inbox2'] })); + + service.getCoarLdnLocalInboxUrls().subscribe((result) => { + expect(result).toEqual(['inbox1', 'inbox2']); + done(); + }); + }); + + it('should return the inbox relation link', () => { + expect(service.getInboxRelationLink()).toBe('http://www.w3.org/ns/ldp#inbox'); + }); +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.ts b/src/app/core/coar-notify/notify-info/notify-info.service.ts new file mode 100644 index 00000000000..455c7902ee4 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { + map, + Observable, +} from 'rxjs'; + +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../data/feature-authorization/feature-id'; +import { RemoteData } from '../../data/remote-data'; +import { ConfigurationProperty } from '../../shared/configuration-property.model'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; + +/** + * Service to check COAR availability and LDN services information for the COAR Notify functionalities + */ +@Injectable({ + providedIn: 'root', +}) +export class NotifyInfoService { + + /** + * The relation link for the inbox + */ + private _inboxRelationLink = 'http://www.w3.org/ns/ldp#inbox'; + + constructor( + private configService: ConfigurationDataService, + protected authorizationService: AuthorizationDataService, + ) {} + + isCoarConfigEnabled(): Observable { + return this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled); + } + + /** + * Get the url of the local inbox from the REST configuration + * @returns the url of the local inbox + */ + getCoarLdnLocalInboxUrls(): Observable { + return this.configService.findByPropertyName('ldn.notify.inbox').pipe( + getFirstCompletedRemoteData(), + map((responseRD: RemoteData) => responseRD.hasSucceeded ? responseRD.payload.values : []), + ); + } + + /** + * Method to get the relation link for the inbox + * @returns the relation link for the inbox + */ + getInboxRelationLink(): string { + return this._inboxRelationLink; + } +} diff --git a/src/app/core/config/bulk-access-config-data.service.ts b/src/app/core/config/bulk-access-config-data.service.ts index 28b4029ea28..8023e58489b 100644 --- a/src/app/core/config/bulk-access-config-data.service.ts +++ b/src/app/core/config/bulk-access-config-data.service.ts @@ -1,17 +1,15 @@ import { Injectable } from '@angular/core'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigDataService } from './config-data.service'; -import { dataService } from '../data/base/data-service.decorator'; -import { BULK_ACCESS_CONDITION_OPTIONS } from './models/config-type'; /** * Data Service responsible for retrieving Bulk Access Condition Options from the REST API */ @Injectable({ providedIn: 'root' }) -@dataService(BULK_ACCESS_CONDITION_OPTIONS) export class BulkAccessConfigDataService extends ConfigDataService { constructor( diff --git a/src/app/core/config/config-data.service.spec.ts b/src/app/core/config/config-data.service.spec.ts index 38340d1ad54..a9979f1bb5b 100644 --- a/src/app/core/config/config-data.service.spec.ts +++ b/src/app/core/config/config-data.service.spec.ts @@ -1,15 +1,16 @@ import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { ConfigDataService } from './config-data.service'; -import { RequestService } from '../data/request.service'; -import { GetRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { FindListOptions } from '../data/find-list-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { GetRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; diff --git a/src/app/core/config/config-data.service.ts b/src/app/core/config/config-data.service.ts index 58b023e62c8..51ca6559219 100644 --- a/src/app/core/config/config-data.service.ts +++ b/src/app/core/config/config-data.service.ts @@ -1,11 +1,11 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ConfigObject } from './models/config.model'; -import { RemoteData } from '../data/remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { RemoteData } from '../data/remote-data'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { ConfigObject } from './models/config.model'; /** * Abstract data service to retrieve configuration objects from the REST server. diff --git a/src/app/core/config/models/bulk-access-condition-options.model.ts b/src/app/core/config/models/bulk-access-condition-options.model.ts index d84e14b95db..514c682b4e5 100644 --- a/src/app/core/config/models/bulk-access-condition-options.model.ts +++ b/src/app/core/config/models/bulk-access-condition-options.model.ts @@ -1,8 +1,13 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { + autoserialize, + autoserializeAs, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { ResourceType } from '../../shared/resource-type'; import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; import { ConfigObject } from './config.model'; import { AccessesConditionOption } from './config-accesses-conditions-options.model'; import { BULK_ACCESS_CONDITION_OPTIONS } from './config-type'; diff --git a/src/app/core/config/models/config-accesses-conditions-options.model.ts b/src/app/core/config/models/config-accesses-conditions-options.model.ts index 244b5019086..64199be0eb7 100644 --- a/src/app/core/config/models/config-accesses-conditions-options.model.ts +++ b/src/app/core/config/models/config-accesses-conditions-options.model.ts @@ -3,43 +3,43 @@ */ export class AccessesConditionOption { - /** + /** * The name for this Access Condition */ - name: string; + name: string; - /** + /** * The groupName for this Access Condition */ - groupName: string; + groupName: string; - /** + /** * A boolean representing if this Access Condition has a start date */ - hasStartDate: boolean; + hasStartDate: boolean; - /** + /** * A boolean representing if this Access Condition has an end date */ - hasEndDate: boolean; + hasEndDate: boolean; - /** + /** * Maximum value of the start date */ - endDateLimit?: string; + endDateLimit?: string; - /** + /** * Maximum value of the end date */ - startDateLimit?: string; + startDateLimit?: string; - /** + /** * Maximum value of the start date */ - maxStartDate?: string; + maxStartDate?: string; - /** + /** * Maximum value of the end date */ - maxEndDate?: string; + maxEndDate?: string; } diff --git a/src/app/core/config/models/config-submission-access.model.ts b/src/app/core/config/models/config-submission-access.model.ts index 7db96acf2bd..2e9a9291835 100644 --- a/src/app/core/config/models/config-submission-access.model.ts +++ b/src/app/core/config/models/config-submission-access.model.ts @@ -1,9 +1,14 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { AccessesConditionOption } from './config-accesses-conditions-options.model'; import { SUBMISSION_ACCESSES_TYPE } from './config-type'; -import { HALLink } from '../../shared/hal-link.model'; /** * Class for the configuration describing the item accesses condition diff --git a/src/app/core/config/models/config-submission-accesses.model.ts b/src/app/core/config/models/config-submission-accesses.model.ts index 3f8004928db..b3c097cc8ac 100644 --- a/src/app/core/config/models/config-submission-accesses.model.ts +++ b/src/app/core/config/models/config-submission-accesses.model.ts @@ -1,7 +1,8 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { SUBMISSION_ACCESSES_TYPE } from './config-type'; import { SubmissionAccessModel } from './config-submission-access.model'; +import { SUBMISSION_ACCESSES_TYPE } from './config-type'; @typedObject @inheritSerialization(SubmissionAccessModel) diff --git a/src/app/core/config/models/config-submission-definition.model.ts b/src/app/core/config/models/config-submission-definition.model.ts index b07917e0328..eda4f54340b 100644 --- a/src/app/core/config/models/config-submission-definition.model.ts +++ b/src/app/core/config/models/config-submission-definition.model.ts @@ -1,9 +1,14 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { PaginatedList } from '../../data/paginated-list.model'; import { HALLink } from '../../shared/hal-link.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; import { ConfigObject } from './config.model'; +import { SubmissionSectionModel } from './config-submission-section.model'; import { SUBMISSION_DEFINITION_TYPE } from './config-type'; /** diff --git a/src/app/core/config/models/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts index 08f1ef17bb0..790334da9bd 100644 --- a/src/app/core/config/models/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionDefinitionModel } from './config-submission-definition.model'; import { SUBMISSION_DEFINITIONS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-form.model.ts b/src/app/core/config/models/config-submission-form.model.ts index 90f94882bd6..c524a839160 100644 --- a/src/app/core/config/models/config-submission-form.model.ts +++ b/src/app/core/config/models/config-submission-form.model.ts @@ -1,7 +1,11 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + inheritSerialization, +} from 'cerialize'; + +import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { typedObject } from '../../cache/builders/build-decorators'; import { ConfigObject } from './config.model'; -import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { SUBMISSION_FORM_TYPE } from './config-type'; /** diff --git a/src/app/core/config/models/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts index 506905d88cb..4cf71d85d9a 100644 --- a/src/app/core/config/models/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionFormModel } from './config-submission-form.model'; import { SUBMISSION_FORMS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts index bdc884dfa46..13e19544dcf 100644 --- a/src/app/core/config/models/config-submission-section.model.ts +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -1,18 +1,19 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + +import { + SectionScope, + SectionVisibility, +} from '../../../submission/objects/section-visibility.model'; import { SectionsType } from '../../../submission/sections/sections-type'; import { typedObject } from '../../cache/builders/build-decorators'; import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { SUBMISSION_SECTION_TYPE } from './config-type'; -/** - * An interface that define section visibility and its properties. - */ -export interface SubmissionSectionVisibility { - main: any; - other: any; -} - @typedObject @inheritSerialization(ConfigObject) export class SubmissionSectionModel extends ConfigObject { @@ -30,6 +31,12 @@ export class SubmissionSectionModel extends ConfigObject { @autoserialize mandatory: boolean; + /** + * The submission scope for this section + */ + @autoserialize + scope: SectionScope; + /** * A string representing the kind of section object */ @@ -37,10 +44,10 @@ export class SubmissionSectionModel extends ConfigObject { sectionType: SectionsType; /** - * The [SubmissionSectionVisibility] object for this section + * The [SectionVisibility] object for this section */ @autoserialize - visibility: SubmissionSectionVisibility; + visibility: SectionVisibility; /** * The {@link HALLink}s for this SubmissionSectionModel diff --git a/src/app/core/config/models/config-submission-sections.model.ts b/src/app/core/config/models/config-submission-sections.model.ts index 423ea99b1e1..86894b6e44b 100644 --- a/src/app/core/config/models/config-submission-sections.model.ts +++ b/src/app/core/config/models/config-submission-sections.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionSectionModel } from './config-submission-section.model'; import { SUBMISSION_SECTIONS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-upload.model.ts b/src/app/core/config/models/config-submission-upload.model.ts index f6897da2e38..cabc84d0f55 100644 --- a/src/app/core/config/models/config-submission-upload.model.ts +++ b/src/app/core/config/models/config-submission-upload.model.ts @@ -1,12 +1,23 @@ -import { autoserialize, inheritSerialization, deserialize } from 'cerialize'; -import { typedObject, link } from '../../cache/builders/build-decorators'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; +import { Observable } from 'rxjs'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { RemoteData } from '../../data/remote-data'; +import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { AccessConditionOption } from './config-access-condition-option.model'; import { SubmissionFormsModel } from './config-submission-forms.model'; -import { SUBMISSION_UPLOAD_TYPE, SUBMISSION_FORMS_TYPE } from './config-type'; -import { HALLink } from '../../shared/hal-link.model'; -import { RemoteData } from '../../data/remote-data'; -import { Observable } from 'rxjs'; +import { + SUBMISSION_FORMS_TYPE, + SUBMISSION_UPLOAD_TYPE, +} from './config-type'; @typedObject @inheritSerialization(ConfigObject) diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts index 8fb7dc66b9c..235cdd31a1e 100644 --- a/src/app/core/config/models/config-submission-uploads.model.ts +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -1,7 +1,8 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { SUBMISSION_UPLOADS_TYPE } from './config-type'; import { SubmissionUploadModel } from './config-submission-upload.model'; +import { SUBMISSION_UPLOADS_TYPE } from './config-type'; @typedObject @inheritSerialization(SubmissionUploadModel) diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 170aa334edb..74d090b89db 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,8 +1,12 @@ -import { autoserialize, deserialize } from 'cerialize'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { CacheableObject } from '../../cache/cacheable-object.model'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { CacheableObject } from '../../cache/cacheable-object.model'; export abstract class ConfigObject implements CacheableObject { diff --git a/src/app/core/config/submission-accesses-config-data.service.ts b/src/app/core/config/submission-accesses-config-data.service.ts index d2da0fce422..bc7a7e66d3c 100644 --- a/src/app/core/config/submission-accesses-config-data.service.ts +++ b/src/app/core/config/submission-accesses-config-data.service.ts @@ -1,22 +1,20 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; import { SubmissionAccessesModel } from './models/config-submission-accesses.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. */ -@Injectable() -@dataService(SUBMISSION_ACCESSES_TYPE) +@Injectable({ providedIn: 'root' }) export class SubmissionAccessesConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/config/submission-forms-config-data.service.ts b/src/app/core/config/submission-forms-config-data.service.ts index f4c0690685b..fe1234defb3 100644 --- a/src/app/core/config/submission-forms-config-data.service.ts +++ b/src/app/core/config/submission-forms-config-data.service.ts @@ -1,22 +1,20 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; -import { RequestService } from '../data/request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; -import { 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) +@Injectable({ providedIn: 'root' }) export class SubmissionFormsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/config/submission-uploads-config-data.service.ts b/src/app/core/config/submission-uploads-config-data.service.ts index 8f838352a9e..10d749080e4 100644 --- a/src/app/core/config/submission-uploads-config-data.service.ts +++ b/src/app/core/config/submission-uploads-config-data.service.ts @@ -1,22 +1,20 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; import { SubmissionUploadsModel } from './models/config-submission-uploads.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. */ -@Injectable() -@dataService(SUBMISSION_UPLOADS_TYPE) +@Injectable({ providedIn: 'root' }) export class SubmissionUploadsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/core-state.model.ts b/src/app/core/core-state.model.ts index b8211fdb555..2128901754b 100644 --- a/src/app/core/core-state.model.ts +++ b/src/app/core/core-state.model.ts @@ -1,16 +1,14 @@ -import { - BitstreamFormatRegistryState -} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { BitstreamFormatRegistryState } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { AuthState } from './auth/auth.reducer'; import { ObjectCacheState } from './cache/object-cache.reducer'; import { ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; import { ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; +import { RequestState } from './data/request-state.model'; import { HistoryState } from './history/history.reducer'; import { MetaIndexState } from './index/index.reducer'; -import { AuthState } from './auth/auth.reducer'; import { JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; import { MetaTagState } from './metadata/meta-tag.reducer'; import { RouteState } from './services/route.reducer'; -import { RequestState } from './data/request-state.model'; /** * The core sub-state in the NgRx store diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index b569df290d5..5af2fe580a1 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,12 +1,13 @@ -import { ObjectCacheEffects } from './cache/object-cache.effects'; -import { UUIDIndexEffects } from './index/index.effects'; -import { RequestEffects } from './data/request.effects'; +import { MenuEffects } from '../shared/menu/menu.effects'; import { AuthEffects } from './auth/auth.effects'; -import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; +import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; -import { RouteEffects } from './services/route.effects'; +import { RequestEffects } from './data/request.effects'; +import { UUIDIndexEffects } from './index/index.effects'; +import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { RouterEffects } from './router/router.effects'; +import { RouteEffects } from './services/route.effects'; export const coreEffects = [ RequestEffects, @@ -18,4 +19,5 @@ export const coreEffects = [ ObjectUpdatesEffects, RouteEffects, RouterEffects, + MenuEffects, ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts deleted file mode 100644 index dbca773375a..00000000000 --- a/src/app/core/core.module.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; - -import { EffectsModule } from '@ngrx/effects'; - -import { Action, StoreConfig, StoreModule } from '@ngrx/store'; -import { MyDSpaceGuard } from '../my-dspace-page/my-dspace.guard'; - -import { isNotEmpty } from '../shared/empty.util'; -import { HostWindowService } from '../shared/host-window.service'; -import { MenuService } from '../shared/menu/menu.service'; -import { EndpointMockingRestService } from '../shared/mocks/dspace-rest/endpoint-mocking-rest.service'; -import { - MOCK_RESPONSE_MAP, - mockResponseMap, - ResponseMapMock -} from '../shared/mocks/dspace-rest/mocks/response-map.mock'; -import { NotificationsService } from '../shared/notifications/notifications.service'; -import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; -import { ObjectSelectService } from '../shared/object-select/object-select.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { AuthenticatedGuard } from './auth/authenticated.guard'; -import { AuthStatus } from './auth/models/auth-status.model'; -import { BrowseService } from './browse/browse.service'; -import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; -import { ObjectCacheService } from './cache/object-cache.service'; -import { SubmissionDefinitionsModel } from './config/models/config-submission-definitions.model'; -import { SubmissionFormsModel } from './config/models/config-submission-forms.model'; -import { SubmissionSectionModel } from './config/models/config-submission-section.model'; -import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; -import { SubmissionFormsConfigDataService } from './config/submission-forms-config-data.service'; -import { coreEffects } from './core.effects'; -import { coreReducers } from './core.reducers'; -import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; -import { CollectionDataService } from './data/collection-data.service'; -import { CommunityDataService } from './data/community-data.service'; -import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; -import { DebugResponseParsingService } from './data/debug-response-parsing.service'; -import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; -import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; -import { DSOResponseParsingService } from './data/dso-response-parsing.service'; -import { DSpaceObjectDataService } from './data/dspace-object-data.service'; -import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; -import { EntityTypeDataService } from './data/entity-type-data.service'; -import { ExternalSourceDataService } from './data/external-source-data.service'; -import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; -import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; -import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; -import { ItemDataService } from './data/item-data.service'; -import { LookupRelationService } from './data/lookup-relation.service'; -import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; -import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; -import { RelationshipTypeDataService } from './data/relationship-type-data.service'; -import { RelationshipDataService } from './data/relationship-data.service'; -import { ResourcePolicyDataService } from './resource-policy/resource-policy-data.service'; -import { SearchResponseParsingService } from './data/search-response-parsing.service'; -import { SiteDataService } from './data/site-data.service'; -import { DspaceRestService } from './dspace-rest/dspace-rest.service'; -import { EPersonDataService } from './eperson/eperson-data.service'; -import { EPerson } from './eperson/models/eperson.model'; -import { Group } from './eperson/models/group.model'; -import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; -import { MetadataField } from './metadata/metadata-field.model'; -import { MetadataSchema } from './metadata/metadata-schema.model'; -import { MetadataService } from './metadata/metadata.service'; -import { RegistryService } from './registry/registry.service'; -import { RoleService } from './roles/role.service'; -import { FeedbackDataService } from './feedback/feedback-data.service'; - -import { ServerResponseService } from './services/server-response.service'; -import { NativeWindowFactory, NativeWindowService } from './services/window.service'; -import { BitstreamFormat } from './shared/bitstream-format.model'; -import { Bitstream } from './shared/bitstream.model'; -import { BrowseDefinition } from './shared/browse-definition.model'; -import { BrowseEntry } from './shared/browse-entry.model'; -import { Bundle } from './shared/bundle.model'; -import { Collection } from './shared/collection.model'; -import { Community } from './shared/community.model'; -import { DSpaceObject } from './shared/dspace-object.model'; -import { ExternalSourceEntry } from './shared/external-source-entry.model'; -import { ExternalSource } from './shared/external-source.model'; -import { HALEndpointService } from './shared/hal-endpoint.service'; -import { ItemType } from './shared/item-relationships/item-type.model'; -import { RelationshipType } from './shared/item-relationships/relationship-type.model'; -import { Relationship } from './shared/item-relationships/relationship.model'; -import { Item } from './shared/item.model'; -import { License } from './shared/license.model'; -import { ResourcePolicy } from './resource-policy/models/resource-policy.model'; -import { SearchConfigurationService } from './shared/search/search-configuration.service'; -import { SearchFilterService } from './shared/search/search-filter.service'; -import { SearchService } from './shared/search/search.service'; -import { Site } from './shared/site.model'; -import { UUIDService } from './shared/uuid.service'; -import { WorkflowItem } from './submission/models/workflowitem.model'; -import { WorkspaceItem } from './submission/models/workspaceitem.model'; -import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; -import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; -import { SubmissionRestService } from './submission/submission-rest.service'; -import { WorkflowItemDataService } from './submission/workflowitem-data.service'; -import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; -import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; -import { ClaimedTask } from './tasks/models/claimed-task-object.model'; -import { PoolTask } from './tasks/models/pool-task-object.model'; -import { TaskObject } from './tasks/models/task-object.model'; -import { PoolTaskDataService } from './tasks/pool-task-data.service'; -import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; -import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; -import { BitstreamDataService } from './data/bitstream-data.service'; -import { environment } from '../../environments/environment'; -import { storeModuleConfig } from '../app.reducer'; -import { VersionDataService } from './data/version-data.service'; -import { VersionHistoryDataService } from './data/version-history-data.service'; -import { Version } from './shared/version.model'; -import { VersionHistory } from './shared/version-history.model'; -import { Script } from '../process-page/scripts/script.model'; -import { Process } from '../process-page/processes/process.model'; -import { ProcessDataService } from './data/processes/process-data.service'; -import { ScriptDataService } from './data/processes/script-data.service'; -import { WorkflowActionDataService } from './data/workflow-action-data.service'; -import { WorkflowAction } from './tasks/models/workflow-action-object.model'; -import { ItemTemplateDataService } from './data/item-template-data.service'; -import { TemplateItem } from './shared/template-item.model'; -import { Feature } from './shared/feature.model'; -import { Authorization } from './shared/authorization.model'; -import { FeatureDataService } from './data/feature-authorization/feature-data.service'; -import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; -import { - SiteAdministratorGuard -} from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; -import { Registration } from './shared/registration.model'; -import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; -import { MetadataFieldDataService } from './data/metadata-field-data.service'; -import { TokenResponseParsingService } from './auth/token-response-parsing.service'; -import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; -import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; -import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; -import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; -import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; -import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; -import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; -import { VocabularyService } from './submission/vocabularies/vocabulary.service'; -import { ConfigurationDataService } from './data/configuration-data.service'; -import { ConfigurationProperty } from './shared/configuration-property.model'; -import { ReloadGuard } from './reload/reload.guard'; -import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; -import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; -import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; -import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; -import { ShortLivedToken } from './auth/models/short-lived-token.model'; -import { UsageReport } from './statistics/models/usage-report.model'; -import { RootDataService } from './data/root-data.service'; -import { Root } from './data/root.model'; -import { SearchConfig } from './shared/search/search-filters/search-config.model'; -import { SequenceService } from './shared/sequence.service'; -import { CoreState } from './core-state.model'; -import { GroupDataService } from './eperson/group-data.service'; -import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; -import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model'; -import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model'; -import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model'; -import { AccessStatusObject } from '../shared/object-collection/shared/badges/access-status-badge/access-status.model'; -import { AccessStatusDataService } from './data/access-status-data.service'; -import { LinkHeadService } from './services/link-head.service'; -import { ResearcherProfileDataService } from './profile/researcher-profile-data.service'; -import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; -import { ResearcherProfile } from './profile/model/researcher-profile.model'; -import { OrcidQueueDataService } from './orcid/orcid-queue-data.service'; -import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; -import { OrcidQueue } from './orcid/model/orcid-queue.model'; -import { OrcidHistory } from './orcid/model/orcid-history.model'; -import { OrcidAuthService } from './orcid/orcid-auth.service'; -import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; -import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; -import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; -import { Subscription } from '../shared/subscriptions/models/subscription.model'; -import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; -import { ItemRequest } from './shared/item-request.model'; -import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; -import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; -import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; -import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; -import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; - -/** - * When not in production, endpoint responses can be mocked for testing purposes - * If there is no mock version available for the endpoint, the actual REST response will be used just like in production mode - */ -export const restServiceFactory = (mocks: ResponseMapMock, http: HttpClient) => { - if (environment.production) { - return new DspaceRestService(http); - } else { - return new EndpointMockingRestService(mocks, http); - } -}; - -const IMPORTS = [ - CommonModule, - StoreModule.forFeature('core', coreReducers, storeModuleConfig as StoreConfig), - EffectsModule.forFeature(coreEffects) -]; - -const DECLARATIONS = []; - -const EXPORTS = []; - -const PROVIDERS = [ - AuthenticatedGuard, - CommunityDataService, - CollectionDataService, - SiteDataService, - DSOResponseParsingService, - { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, - EPersonDataService, - LinkHeadService, - HALEndpointService, - HostWindowService, - ItemDataService, - MetadataService, - ObjectCacheService, - PaginationComponentOptions, - ResourcePolicyDataService, - RegistryService, - BitstreamFormatDataService, - RemoteDataBuildService, - EndpointMapResponseParsingService, - FacetValueResponseParsingService, - FacetConfigResponseParsingService, - DebugResponseParsingService, - SearchResponseParsingService, - MyDSpaceResponseParsingService, - ServerResponseService, - BrowseService, - AccessStatusDataService, - SubmissionCcLicenseDataService, - SubmissionCcLicenseUrlDataService, - SubmissionFormsConfigDataService, - SubmissionRestService, - SubmissionResponseParsingService, - SubmissionJsonPatchOperationsService, - JsonPatchOperationsBuilder, - UUIDService, - NotificationsService, - WorkspaceitemDataService, - WorkflowItemDataService, - DSpaceObjectDataService, - ConfigurationDataService, - DSOChangeAnalyzer, - DefaultChangeAnalyzer, - ArrayMoveChangeAnalyzer, - ObjectSelectService, - MenuService, - ObjectUpdatesService, - SearchService, - RelationshipDataService, - MyDSpaceGuard, - RoleService, - TaskResponseParsingService, - ClaimedTaskDataService, - PoolTaskDataService, - BitstreamDataService, - EntityTypeDataService, - ContentSourceResponseParsingService, - ItemTemplateDataService, - SearchService, - SidebarService, - SearchFilterService, - SearchFilterService, - SearchConfigurationService, - SelectableListService, - RelationshipTypeDataService, - ExternalSourceDataService, - LookupRelationService, - VersionDataService, - VersionHistoryDataService, - WorkflowActionDataService, - ProcessDataService, - ScriptDataService, - FeatureDataService, - AuthorizationDataService, - SiteAdministratorGuard, - SiteRegisterGuard, - MetadataSchemaDataService, - MetadataFieldDataService, - TokenResponseParsingService, - ReloadGuard, - EndUserAgreementCurrentUserGuard, - EndUserAgreementCookieGuard, - EndUserAgreementService, - RootDataService, - NotificationsService, - FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory }, - VocabularyService, - VocabularyDataService, - VocabularyEntryDetailsDataService, - SequenceService, - GroupDataService, - FeedbackDataService, - ResearcherProfileDataService, - ProfileClaimService, - OrcidAuthService, - OrcidQueueDataService, - OrcidHistoryDataService, - SupervisionOrderDataService -]; - -/** - * Declaration needed to make sure all decorator functions are called in time - */ -export const models = - [ - Root, - DSpaceObject, - Bundle, - Bitstream, - BitstreamFormat, - Item, - Site, - Collection, - Community, - EPerson, - Group, - ResourcePolicy, - MetadataSchema, - MetadataField, - License, - WorkflowItem, - WorkspaceItem, - SubmissionCcLicence, - SubmissionCcLicenceUrl, - SubmissionDefinitionsModel, - SubmissionFormsModel, - SubmissionSectionModel, - SubmissionUploadsModel, - AuthStatus, - BrowseEntry, - BrowseDefinition, - NonHierarchicalBrowseDefinition, - FlatBrowseDefinition, - ValueListBrowseDefinition, - HierarchicalBrowseDefinition, - ClaimedTask, - TaskObject, - PoolTask, - Relationship, - RelationshipType, - ItemType, - ExternalSource, - ExternalSourceEntry, - Script, - Process, - Version, - VersionHistory, - WorkflowAction, - AdvancedWorkflowInfo, - RatingAdvancedWorkflowInfo, - SelectReviewerAdvancedWorkflowInfo, - TemplateItem, - Feature, - Authorization, - Registration, - Vocabulary, - VocabularyEntry, - VocabularyEntryDetail, - ConfigurationProperty, - ShortLivedToken, - Registration, - UsageReport, - Root, - SearchConfig, - SubmissionAccessesModel, - AccessStatusObject, - ResearcherProfile, - OrcidQueue, - OrcidHistory, - AccessStatusObject, - IdentifierData, - Subscription, - ItemRequest, - BulkAccessConditionOptions - ]; - -@NgModule({ - imports: [ - ...IMPORTS - ], - declarations: [ - ...DECLARATIONS - ], - exports: [ - ...EXPORTS - ], - providers: [ - ...PROVIDERS - ] -}) - -export class CoreModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: CoreModule, - providers: [ - ...PROVIDERS - ] - }; - } - - constructor(@Optional() @SkipSelf() parentModule: CoreModule) { - if (isNotEmpty(parentModule)) { - throw new Error('CoreModule is already loaded. Import it in the AppModule only'); - } - } -} diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c0165c53848..fda1e05df05 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,19 +1,17 @@ -import { ActionReducerMap, } from '@ngrx/store'; +import { ActionReducerMap } from '@ngrx/store'; -import { objectCacheReducer } from './cache/object-cache.reducer'; -import { indexReducer } from './index/index.reducer'; -import { requestReducer } from './data/request.reducer'; +import { bitstreamFormatReducer } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { authReducer } from './auth/auth.reducer'; -import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; +import { objectCacheReducer } from './cache/object-cache.reducer'; import { serverSyncBufferReducer } from './cache/server-sync-buffer.reducer'; +import { CoreState } from './core-state.model'; import { objectUpdatesReducer } from './data/object-updates/object-updates.reducer'; -import { routeReducer } from './services/route.reducer'; -import { - bitstreamFormatReducer -} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { requestReducer } from './data/request.reducer'; import { historyReducer } from './history/history.reducer'; +import { indexReducer } from './index/index.reducer'; +import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; import { metaTagReducer } from './metadata/meta-tag.reducer'; -import { CoreState } from './core-state.model'; +import { routeReducer } from './services/route.reducer'; export const coreReducers: ActionReducerMap = { 'bitstreamFormats': bitstreamFormatReducer, @@ -26,5 +24,5 @@ export const coreReducers: ActionReducerMap = { 'auth': authReducer, 'json/patch': jsonPatchOperationsReducer, 'metaTag': metaTagReducer, - 'route': routeReducer + 'route': routeReducer, }; diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts index 77c7974de2c..899afb9be98 100644 --- a/src/app/core/core.selectors.ts +++ b/src/app/core/core.selectors.ts @@ -1,4 +1,5 @@ import { createFeatureSelector } from '@ngrx/store'; + import { CoreState } from './core-state.model'; /** diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts new file mode 100644 index 00000000000..c9ebbc5ffc3 --- /dev/null +++ b/src/app/core/data-services-map.ts @@ -0,0 +1,139 @@ +import { LazyDataServicesMap } from '../../config/app-config.interface'; +import { + LDN_SERVICE, + LDN_SERVICE_CONSTRAINT_FILTERS, +} from '../admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type'; +import { ADMIN_NOTIFY_MESSAGE } from '../admin/admin-notify-dashboard/models/admin-notify-message.resource-type'; +import { NOTIFYREQUEST } from '../item-page/simple/notify-requests-status/notify-requests-status.resource-type'; +import { PROCESS } from '../process-page/processes/process.resource-type'; +import { SCRIPT } from '../process-page/scripts/script.resource-type'; +import { ACCESS_STATUS } from '../shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; +import { DUPLICATE } from '../shared/object-list/duplicate-data/duplicate.resource-type'; +import { IDENTIFIERS } from '../shared/object-list/identifier-data/identifier-data.resource-type'; +import { SUBSCRIPTION } from '../shared/subscriptions/models/subscription.resource-type'; +import { SUBMISSION_COAR_NOTIFY_CONFIG } from '../submission/sections/section-coar-notify/section-coar-notify-service.resource-type'; +import { SYSTEMWIDEALERT } from '../system-wide-alert/system-wide-alert.resource-type'; +import { + BULK_ACCESS_CONDITION_OPTIONS, + SUBMISSION_ACCESSES_TYPE, + SUBMISSION_FORMS_TYPE, + SUBMISSION_UPLOADS_TYPE, +} from './config/models/config-type'; +import { ROOT } from './data/root.resource-type'; +import { EPERSON } from './eperson/models/eperson.resource-type'; +import { GROUP } from './eperson/models/group.resource-type'; +import { WORKFLOWITEM } from './eperson/models/workflowitem.resource-type'; +import { WORKSPACEITEM } from './eperson/models/workspaceitem.resource-type'; +import { FEEDBACK } from './feedback/models/feedback.resource-type'; +import { METADATA_FIELD } from './metadata/metadata-field.resource-type'; +import { METADATA_SCHEMA } from './metadata/metadata-schema.resource-type'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from './notifications/qa/models/quality-assurance-event-object.resource-type'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from './notifications/qa/models/quality-assurance-source-object.resource-type'; +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from './notifications/qa/models/quality-assurance-topic-object.resource-type'; +import { SUGGESTION } from './notifications/suggestions/models/suggestion-objects.resource-type'; +import { SUGGESTION_SOURCE } from './notifications/suggestions/models/suggestion-source-object.resource-type'; +import { SUGGESTION_TARGET } from './notifications/suggestions/models/suggestion-target-object.resource-type'; +import { ORCID_HISTORY } from './orcid/model/orcid-history.resource-type'; +import { ORCID_QUEUE } from './orcid/model/orcid-queue.resource-type'; +import { RESEARCHER_PROFILE } from './profile/model/researcher-profile.resource-type'; +import { RESOURCE_POLICY } from './resource-policy/models/resource-policy.resource-type'; +import { AUTHORIZATION } from './shared/authorization.resource-type'; +import { BITSTREAM } from './shared/bitstream.resource-type'; +import { BITSTREAM_FORMAT } from './shared/bitstream-format.resource-type'; +import { BROWSE_DEFINITION } from './shared/browse-definition.resource-type'; +import { BUNDLE } from './shared/bundle.resource-type'; +import { COLLECTION } from './shared/collection.resource-type'; +import { COMMUNITY } from './shared/community.resource-type'; +import { CONFIG_PROPERTY } from './shared/config-property.resource-type'; +import { DSPACE_OBJECT } from './shared/dspace-object.resource-type'; +import { FEATURE } from './shared/feature.resource-type'; +import { ITEM } from './shared/item.resource-type'; +import { ITEM_TYPE } from './shared/item-relationships/item-type.resource-type'; +import { RELATIONSHIP } from './shared/item-relationships/relationship.resource-type'; +import { RELATIONSHIP_TYPE } from './shared/item-relationships/relationship-type.resource-type'; +import { LICENSE } from './shared/license.resource-type'; +import { SITE } from './shared/site.resource-type'; +import { VERSION } from './shared/version.resource-type'; +import { VERSION_HISTORY } from './shared/version-history.resource-type'; +import { USAGE_REPORT } from './statistics/models/usage-report.resource-type'; +import { CorrectionType } from './submission/models/correctiontype.model'; +import { SUBMISSION_CC_LICENSE } from './submission/models/submission-cc-licence.resource-type'; +import { SUBMISSION_CC_LICENSE_URL } from './submission/models/submission-cc-licence-link.resource-type'; +import { + VOCABULARY, + VOCABULARY_ENTRY, + VOCABULARY_ENTRY_DETAIL, +} from './submission/vocabularies/models/vocabularies.resource-type'; +import { SUPERVISION_ORDER } from './supervision-order/models/supervision-order.resource-type'; +import { CLAIMED_TASK } from './tasks/models/claimed-task-object.resource-type'; +import { POOL_TASK } from './tasks/models/pool-task-object.resource-type'; +import { WORKFLOW_ACTION } from './tasks/models/workflow-action-object.resource-type'; + +export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ + [AUTHORIZATION.value, () => import('./data/feature-authorization/authorization-data.service').then(m => m.AuthorizationDataService)], + [BROWSE_DEFINITION.value, () => import('./browse/browse-definition-data.service').then(m => m.BrowseDefinitionDataService)], + [BULK_ACCESS_CONDITION_OPTIONS.value, () => import('./config/bulk-access-config-data.service').then(m => m.BulkAccessConfigDataService)], + [METADATA_SCHEMA.value, () => import('./data/metadata-schema-data.service').then(m => m.MetadataSchemaDataService)], + [SUBMISSION_UPLOADS_TYPE.value, () => import('./config/submission-uploads-config-data.service').then(m => m.SubmissionUploadsConfigDataService)], + [BITSTREAM.value, () => import('./data/bitstream-data.service').then(m => m.BitstreamDataService)], + [SUBMISSION_ACCESSES_TYPE.value, () => import('./config/submission-accesses-config-data.service').then(m => m.SubmissionAccessesConfigDataService)], + [SYSTEMWIDEALERT.value, () => import('./data/system-wide-alert-data.service').then(m => m.SystemWideAlertDataService)], + [USAGE_REPORT.value, () => import('./statistics/usage-report-data.service').then(m => m.UsageReportDataService)], + [ACCESS_STATUS.value, () => import('./data/access-status-data.service').then(m => m.AccessStatusDataService)], + [COLLECTION.value, () => import('./data/collection-data.service').then(m => m.CollectionDataService)], + [CLAIMED_TASK.value, () => import('./tasks/claimed-task-data.service').then(m => m.ClaimedTaskDataService)], + [VOCABULARY_ENTRY.value, () => import('./data/href-only-data.service').then(m => m.HrefOnlyDataService)], + [ITEM_TYPE.value, () => import('./data/href-only-data.service').then(m => m.HrefOnlyDataService)], + [LICENSE.value, () => import('./data/href-only-data.service').then(m => m.HrefOnlyDataService)], + [SUBSCRIPTION.value, () => import('../shared/subscriptions/subscriptions-data.service').then(m => m.SubscriptionsDataService)], + [COMMUNITY.value, () => import('./data/community-data.service').then(m => m.CommunityDataService)], + [VOCABULARY.value, () => import('./submission/vocabularies/vocabulary.data.service').then(m => m.VocabularyDataService)], + [BUNDLE.value, () => import('./data/bundle-data.service').then(m => m.BundleDataService)], + [CONFIG_PROPERTY.value, () => import('./data/configuration-data.service').then(m => m.ConfigurationDataService)], + [POOL_TASK.value, () => import('./tasks/pool-task-data.service').then(m => m.PoolTaskDataService)], + [CLAIMED_TASK.value, () => import('./tasks/claimed-task-data.service').then(m => m.ClaimedTaskDataService)], + [SUPERVISION_ORDER.value, () => import('./supervision-order/supervision-order-data.service').then(m => m.SupervisionOrderDataService)], + [WORKSPACEITEM.value, () => import('./submission/workspaceitem-data.service').then(m => m.WorkspaceitemDataService)], + [WORKFLOWITEM.value, () => import('./submission/workflowitem-data.service').then(m => m.WorkflowItemDataService)], + [VOCABULARY.value, () => import('./submission/vocabularies/vocabulary.data.service').then(m => m.VocabularyDataService)], + [VOCABULARY_ENTRY_DETAIL.value, () => import('./submission/vocabularies/vocabulary-entry-details.data.service').then(m => m.VocabularyEntryDetailsDataService)], + [SUBMISSION_CC_LICENSE_URL.value, () => import('./submission/submission-cc-license-url-data.service').then(m => m.SubmissionCcLicenseUrlDataService)], + [SUBMISSION_CC_LICENSE.value, () => import('./submission/submission-cc-license-data.service').then(m => m.SubmissionCcLicenseDataService)], + [USAGE_REPORT.value, () => import('./statistics/usage-report-data.service').then(m => m.UsageReportDataService)], + [RESOURCE_POLICY.value, () => import('./resource-policy/resource-policy-data.service').then(m => m.ResourcePolicyDataService)], + [RESEARCHER_PROFILE.value, () => import('./profile/researcher-profile-data.service').then(m => m.ResearcherProfileDataService)], + [ORCID_QUEUE.value, () => import('./orcid/orcid-queue-data.service').then(m => m.OrcidQueueDataService)], + [ORCID_HISTORY.value, () => import('./orcid/orcid-history-data.service').then(m => m.OrcidHistoryDataService)], + [FEEDBACK.value, () => import('./feedback/feedback-data.service').then(m => m.FeedbackDataService)], + [GROUP.value, () => import('./eperson/group-data.service').then(m => m.GroupDataService)], + [EPERSON.value, () => import('./eperson/eperson-data.service').then(m => m.EPersonDataService)], + [WORKFLOW_ACTION.value, () => import('./data/workflow-action-data.service').then(m => m.WorkflowActionDataService)], + [VERSION_HISTORY.value, () => import('./data/version-history-data.service').then(m => m.VersionHistoryDataService)], + [SITE.value, () => import('./data/site-data.service').then(m => m.SiteDataService)], + [ROOT.value, () => import('./data/root-data.service').then(m => m.RootDataService)], + [RELATIONSHIP_TYPE.value, () => import('./data/relationship-type-data.service').then(m => m.RelationshipTypeDataService)], + [RELATIONSHIP.value, () => import('./data/relationship-data.service').then(m => m.RelationshipDataService)], + [SCRIPT.value, () => import('./data/processes/script-data.service').then(m => m.ScriptDataService)], + [PROCESS.value, () => import('./data/processes/process-data.service').then(m => m.ProcessDataService)], + [METADATA_FIELD.value, () => import('./data/metadata-field-data.service').then(m => m.MetadataFieldDataService)], + [ITEM.value, () => import('./data/item-data.service').then(m => m.ItemDataService)], + [VERSION.value, () => import('./data/version-data.service').then(m => m.VersionDataService)], + [IDENTIFIERS.value, () => import('./data/identifier-data.service').then(m => m.IdentifierDataService)], + [FEATURE.value, () => import('./data/feature-authorization/authorization-data.service').then(m => m.AuthorizationDataService)], + [DSPACE_OBJECT.value, () => import('./data/dspace-object-data.service').then(m => m.DSpaceObjectDataService)], + [BITSTREAM_FORMAT.value, () => import('./data/bitstream-format-data.service').then(m => m.BitstreamFormatDataService)], + [SUBMISSION_COAR_NOTIFY_CONFIG.value, () => import('../submission/sections/section-coar-notify/coar-notify-config-data.service').then(m => m.CoarNotifyConfigDataService)], + [LDN_SERVICE_CONSTRAINT_FILTERS.value, () => import('../admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service').then(m => m.LdnItemfiltersService)], + [LDN_SERVICE.value, () => import('../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service').then(m => m.LdnServicesService)], + [ADMIN_NOTIFY_MESSAGE.value, () => import('../admin/admin-notify-dashboard/services/admin-notify-messages.service').then(m => m.AdminNotifyMessagesService)], + [SUBMISSION_FORMS_TYPE.value, () => import('./config/submission-forms-config-data.service').then(m => m.SubmissionFormsConfigDataService)], + [NOTIFYREQUEST.value, () => import('./data/notify-services-status-data.service').then(m => m.NotifyRequestsStatusDataService)], + [QUALITY_ASSURANCE_EVENT_OBJECT.value, () => import('./notifications/qa/events/quality-assurance-event-data.service').then(m => m.QualityAssuranceEventDataService)], + [QUALITY_ASSURANCE_SOURCE_OBJECT.value, () => import('./notifications/qa/source/quality-assurance-source-data.service').then(m => m.QualityAssuranceSourceDataService)], + [QUALITY_ASSURANCE_TOPIC_OBJECT.value, () => import('./notifications/qa/topics/quality-assurance-topic-data.service').then(m => m.QualityAssuranceTopicDataService)], + [SUGGESTION.value, () => import('./notifications/suggestions/suggestion-data.service').then(m => m.SuggestionDataService)], + [SUGGESTION_SOURCE.value, () => import('./notifications/suggestions/source/suggestion-source-data.service').then(m => m.SuggestionSourceDataService)], + [SUGGESTION_TARGET.value, () => import('./notifications/suggestions/target/suggestion-target-data.service').then(m => m.SuggestionTargetDataService)], + [DUPLICATE.value, () => import('./submission/submission-duplicate-data.service').then(m => m.SubmissionDuplicateDataService)], + [CorrectionType.type.value, () => import('./submission/correctiontype-data.service').then(m => m.CorrectionTypeDataService)], +]); diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts index 18b8cb5d65d..1240585027f 100644 --- a/src/app/core/data/access-status-data.service.spec.ts +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -1,17 +1,21 @@ -import { RequestService } from './request.service'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { Observable } from 'rxjs'; + +import { hasNoValue } from '../../shared/empty.util'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { GetRequest } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { hasNoValue } from '../../shared/empty.util'; -import { AccessStatusDataService } from './access-status-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Item } from '../shared/item.model'; +import { AccessStatusDataService } from './access-status-data.service'; +import { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; +import { RequestService } from './request.service'; const url = 'fake-url'; @@ -29,12 +33,12 @@ describe('AccessStatusDataService', () => { name: 'test-item', _links: { accessStatus: { - href: `https://rest.api/items/${itemId}/accessStatus` + href: `https://rest.api/items/${itemId}/accessStatus`, }, self: { - href: `https://rest.api/items/${itemId}` - } - } + href: `https://rest.api/items/${itemId}`, + }, + }, }); describe('when the requests are successful', () => { @@ -69,10 +73,10 @@ describe('AccessStatusDataService', () => { } rdbService = jasmine.createSpyObj('rdbService', { buildFromRequestUUID: buildResponse$, - buildSingle: buildResponse$ + buildSingle: buildResponse$, }); objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts index e8b77245e87..6d8acb1c8b4 100644 --- a/src/app/core/data/access-status-data.service.ts +++ b/src/app/core/data/access-status-data.service.ts @@ -1,21 +1,19 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; -import { ACCESS_STATUS } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; import { Item } from '../shared/item.model'; import { BaseDataService } from './base/base-data.service'; -import { dataService } from './base/data-service.decorator'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Data service responsible for retrieving the access status of Items */ -@Injectable() -@dataService(ACCESS_STATUS) +@Injectable({ providedIn: 'root' }) export class AccessStatusDataService extends BaseDataService { constructor( diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts index 025791d6dc4..cf11decde4c 100644 --- a/src/app/core/data/array-move-change-analyzer.service.spec.ts +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -1,7 +1,8 @@ -import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; import { moveItemInArray } from '@angular/cdk/drag-drop'; import { Operation } from 'fast-json-patch'; +import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; + /** * Helper class for creating move tests * Define a "from" and "to" index to move objects within the array before comparing @@ -28,7 +29,7 @@ describe('ArrayMoveChangeAnalyzer', () => { '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', 'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1', '0f608168-cdfc-46b0-92ce-889f7d3ac684', - '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + '546f9f5c-15dc-4eec-86fe-648007ac9e1c', ]; }); @@ -72,7 +73,7 @@ describe('ArrayMoveChangeAnalyzer', () => { '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', undefined, undefined, - '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + '546f9f5c-15dc-4eec-86fe-648007ac9e1c', ]; }); diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts index 36744e9f96e..bd5fc8dedb3 100644 --- a/src/app/core/data/array-move-change-analyzer.service.ts +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -1,12 +1,13 @@ -import { MoveOperation } from 'fast-json-patch'; -import { Injectable } from '@angular/core'; import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { Injectable } from '@angular/core'; +import { MoveOperation } from 'fast-json-patch'; + import { hasValue } from '../../shared/empty.util'; /** * A class to determine move operations between two arrays */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ArrayMoveChangeAnalyzer { /** diff --git a/src/app/core/data/base-response-parsing.service.spec.ts b/src/app/core/data/base-response-parsing.service.spec.ts index da9fa7a6432..f6707d3582a 100644 --- a/src/app/core/data/base-response-parsing.service.spec.ts +++ b/src/app/core/data/base-response-parsing.service.spec.ts @@ -1,9 +1,9 @@ /* eslint-disable max-classes-per-file */ -import { BaseResponseParsingService } from './base-response-parsing.service'; +import { CacheableObject } from '../cache/cacheable-object.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { GetRequest} from './request.models'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { GetRequest } from './request.models'; import { RestRequest } from './rest-request.model'; class TestService extends BaseResponseParsingService { @@ -35,7 +35,7 @@ describe('BaseResponseParsingService', () => { beforeEach(() => { obj = undefined; objectCache = jasmine.createSpyObj('objectCache', { - add: {} + add: {}, }); service = new TestService(objectCache); }); @@ -58,8 +58,8 @@ describe('BaseResponseParsingService', () => { beforeEach(() => { obj = Object.assign(new DSpaceObject(), { _links: { - self: { href: 'obj-selflink' } - } + self: { href: 'obj-selflink' }, + }, }); }); @@ -79,8 +79,8 @@ describe('BaseResponseParsingService', () => { data = { type: 'NotARealType', _links: { - self: { href: 'data-selflink' } - } + self: { href: 'data-selflink' }, + }, }; }); diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 18e6623683f..63b4961b313 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -1,14 +1,21 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { getClassForType } from '../cache/builders/build-decorators'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { environment } from '../../../environments/environment'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { PageInfo } from '../shared/page-info.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; import { RestRequest } from './rest-request.model'; @@ -64,7 +71,7 @@ export abstract class BaseResponseParsingService { } else if (isRestDataObject(data._embedded[property])) { object[property] = this.retrieveObjectOrUrl(parsedObj); } else if (Array.isArray(parsedObj)) { - object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)); + object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)); } } }); @@ -96,14 +103,14 @@ export abstract class BaseResponseParsingService { list = this.flattenSingleKeyObject(list); } const page: ObjectDomain[] = this.processArray(list, request); - return buildPaginatedList(pageInfo, page,); + return buildPaginatedList(pageInfo, page); } protected processArray(data: any, request: RestRequest): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, request)]; - } + array = [...array, this.process(datum, request)]; + }, ); return array; } @@ -139,7 +146,7 @@ export abstract class BaseResponseParsingService { let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index 098f075c101..fc3704bce56 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -5,22 +5,37 @@ * * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { HALLink } from '../../shared/hal-link.model'; +import { FindListOptions } from '../find-list-options.model'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; const endpoint = 'https://rest.api/core'; @@ -46,38 +61,23 @@ describe('BaseDataService', () => { let requestService; let halService; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; let selfLink; let linksToFollow; let testScheduler; - let remoteDataMocks; + let remoteDataTimestamp: number; + let remoteDataMocks: { [responseType: string]: RemoteData }; + let remoteDataPageMocks: { [responseType: string]: RemoteData }; function initTestService(): TestService { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - objectCache = { - - addPatch: () => { - /* empty */ - }, - getObjectBySelfLink: () => { - /* empty */ - }, - getByHref: () => { - /* empty */ - }, - addDependency: () => { - /* empty */ - }, - removeDependents: () => { - /* empty */ - }, - } as any; + objectCache = new ObjectCacheServiceStub(); selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -86,25 +86,57 @@ describe('BaseDataService', () => { expect(actual).toEqual(expected); }); - const timeStamp = new Date().getTime(); + // The response's lastUpdated equals the time of 60 seconds after the test started, ensuring they are not perceived + // as cached values. + remoteDataTimestamp = new Date().getTime() + 60 * 1000; const msToLive = 15 * 60 * 1000; - const payload = { foo: 'bar' }; + const payload = { + foo: 'bar', + followLink1: {}, + followLink2: {}, + _links: { + self: Object.assign(new HALLink(), { + href: 'self-test-link', + }), + followLink1: Object.assign(new HALLink(), { + href: 'follow-link-1', + }), + followLink2: [ + Object.assign(new HALLink(), { + href: 'follow-link-2-1', + }), + Object.assign(new HALLink(), { + href: 'follow-link-2-2', + }), + ], + }, + }; const statusCodeSuccess = 200; const statusCodeError = 404; const errorMessage = 'not found'; 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), + RequestPending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + remoteDataPageMocks = { + RequestPending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), + SuccessStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), + Error: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; return new TestService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } @@ -303,19 +335,21 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataMocks.ResponsePendingStale, + b: remoteDataMocks.SuccessStale, + c: remoteDataMocks.ErrorStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, }; expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe(expected, values); @@ -330,11 +364,15 @@ describe('BaseDataService', () => { spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); }); - - it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + it('should not emit a cached completed RemoteData', () => { + // Old cached value from 1 minute before the test started + const oldCachedSucceededData: RemoteData = Object.assign({}, remoteDataPageMocks.Success, { + timeCompleted: remoteDataTimestamp - 2 * 60 * 1000, + lastUpdated: remoteDataTimestamp - 2 * 60 * 1000, + } as RemoteData); testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, + a: oldCachedSucceededData, b: remoteDataMocks.RequestPending, c: remoteDataMocks.ResponsePending, d: remoteDataMocks.Success, @@ -352,21 +390,39 @@ describe('BaseDataService', () => { }); }); + it('should emit the first completed RemoteData since the request was made', () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b', { + a: remoteDataMocks.Success, + b: remoteDataMocks.SuccessStale, + })); + const expected = 'a-b'; + const values = { + a: remoteDataMocks.Success, + b: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findByHref(selfLink, false, true, ...linksToFollow)).toBe(expected, values); + }); + }); + it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataMocks.ResponsePendingStale, + b: remoteDataMocks.SuccessStale, + c: remoteDataMocks.ErrorStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, }; expectObservable(service.findByHref(selfLink, false, true, ...linksToFollow)).toBe(expected, values); @@ -375,6 +431,22 @@ describe('BaseDataService', () => { }); + it('should link all the followLinks of a cached object by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', { + a: remoteDataMocks.Success, + })); + const expected = 'a'; + const values = { + a: remoteDataMocks.Success, + }; + + expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); describe(`findListByHref`, () => { @@ -387,8 +459,8 @@ describe('BaseDataService', () => { it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { testScheduler.run(({ cold }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); @@ -398,8 +470,8 @@ describe('BaseDataService', () => { it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); @@ -414,8 +486,8 @@ describe('BaseDataService', () => { it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); @@ -426,12 +498,12 @@ describe('BaseDataService', () => { it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData @@ -446,7 +518,7 @@ describe('BaseDataService', () => { it(`should return a the output from reRequestStaleRemoteData`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); const expected = 'a'; const values = { @@ -466,19 +538,19 @@ describe('BaseDataService', () => { it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = 'a-b-c-d-e'; const values = { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -487,19 +559,21 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -514,22 +588,42 @@ describe('BaseDataService', () => { spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); }); - - it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + it('should not emit a cached completed RemoteData', () => { testScheduler.run(({ cold, expectObservable }) => { + // Old cached value from 1 minute before the test started + const oldCachedSucceededData: RemoteData = Object.assign({}, remoteDataPageMocks.Success, { + timeCompleted: remoteDataTimestamp - 2 * 60 * 1000, + lastUpdated: remoteDataTimestamp - 2 * 60 * 1000, + } as RemoteData); spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: oldCachedSucceededData, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = '--b-c-d-e'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, + }; + + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + it('should emit the first completed RemoteData since the request was made', () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b', { + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.SuccessStale, + })); + const expected = 'a-b'; + const values = { + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); @@ -538,25 +632,49 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); + it('should link all the followLinks of the cached objects by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', { + a: remoteDataPageMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + }; + + expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); }); @@ -566,8 +684,8 @@ describe('BaseDataService', () => { beforeEach(() => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ requestUUIDs: ['request1', 'request2', 'request3'], - dependentRequestUUIDs: ['request4', 'request5'] - })); + dependentRequestUUIDs: ['request4', 'request5'], + } as ObjectCacheEntry)); }); @@ -714,7 +832,7 @@ describe('BaseDataService', () => { (service as any).addDependency( createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }), - observableOf('dependsOnHref') + observableOf('dependsOnHref'), ); expect(addDependencySpy).toHaveBeenCalled(); }); @@ -729,7 +847,7 @@ describe('BaseDataService', () => { (service as any).addDependency( createFailedRemoteDataObject$('something went wrong'), - observableOf('dependsOnHref') + observableOf('dependsOnHref'), ); expect(addDependencySpy).toHaveBeenCalled(); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index edd6d9e2a42..aec9e83b1e9 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -6,34 +6,54 @@ * http://www.dspace.org/license/ */ -import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { + AsyncSubject, + from as observableFrom, + Observable, + of as observableOf, + shareReplay, +} from 'rxjs'; +import { + map, + mergeMap, + skipWhile, + switchMap, + take, + tap, + toArray, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { RequestParam } from '../../cache/models/request-param.model'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { HALLink } from '../../shared/hal-link.model'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; import { GetRequest } from '../request.models'; import { RequestService } from '../request.service'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { FindListOptions } from '../find-list-options.model'; -import { PaginatedList } from '../paginated-list.model'; -import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; -import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; export const EMBED_SEPARATOR = '%2F'; /** * Common functionality for data services. * Specific functionality that not all services would need - * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * is implemented in "UpdateDataServiceImpl feature" classes (e.g. {@link CreateData} * - * All DataService (or DataService feature) classes must + * All UpdateDataServiceImpl (or UpdateDataServiceImpl feature) classes must * - extend this class (or {@link IdentifiableDataService}) - * - implement any DataService features it requires in order to forward calls to it + * - implement any UpdateDataServiceImpl features it requires in order to forward calls to it * * ``` * export class SomeDataService extends BaseDataService implements CreateData, SearchData { @@ -235,7 +255,7 @@ export class BaseDataService implements HALDataServic if (hasValue(remoteData) && remoteData.isStale) { requestFn(); } - }) + }), ); } else { return source; @@ -264,19 +284,43 @@ export class BaseDataService implements HALDataServic isNotEmptyOperator(), take(1), map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), + shareReplay({ + bufferSize: 1, + refCount: true, + }), ); + const startTime: number = new Date().getTime(); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + const response$: Observable> = this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // cached completed object - skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + skipWhile((rd: RemoteData) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)), this.reRequestStaleRemoteData(reRequestOnStale, () => this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData) => { + if (hasValue(remoteDataObject?.payload?._links)) { + for (const followLinkName of Object.keys(remoteDataObject.payload._links)) { + // only add the followLinks if they are embedded + if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + }), + ); } /** @@ -298,19 +342,47 @@ export class BaseDataService implements HALDataServic isNotEmptyOperator(), take(1), map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), + shareReplay({ + bufferSize: 1, + refCount: true, + }), ); + const startTime: number = new Date().getTime(); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + const response$: Observable>> = this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // cached completed object - skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + skipWhile((rd: RemoteData>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)), this.reRequestStaleRemoteData(reRequestOnStale, () => this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData>) => { + if (hasValue(remoteDataObject?.payload?.page)) { + for (const object of remoteDataObject.payload.page) { + if (hasValue(object?._links)) { + for (const followLinkName of Object.keys(object._links)) { + // only add the followLinks if they are embedded + if (hasValue(object[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(object._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + } + } + }), + ); } /** @@ -329,7 +401,7 @@ export class BaseDataService implements HALDataServic href$.pipe( isNotEmptyOperator(), - take(1) + take(1), ).subscribe((href: string) => { const requestId = this.requestService.generateRequestId(); const request = new GetRequest(requestId, href); @@ -373,19 +445,19 @@ export class BaseDataService implements HALDataServic return this.hasCachedResponse(href$).pipe( switchMap((hasCachedResponse) => { if (hasCachedResponse) { - return this.rdbService.buildSingle(href$).pipe( - getFirstCompletedRemoteData(), - map((rd => rd.hasFailed)) - ); + return this.rdbService.buildSingle(href$).pipe( + getFirstCompletedRemoteData(), + map((rd => rd.hasFailed)), + ); } return observableOf(false); - }) + }), ); } /** * Return the links to traverse from the root of the api to the - * endpoint this DataService represents + * endpoint this UpdateDataServiceImpl 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 @@ -420,7 +492,7 @@ export class BaseDataService implements HALDataServic } }), ), - dependsOnHref$ + dependsOnHref$, ); } @@ -437,7 +509,7 @@ export class BaseDataService implements HALDataServic switchMap((oce: ObjectCacheEntry) => { return observableFrom([ ...oce.requestUUIDs, - ...oce.dependentRequestUUIDs + ...oce.dependentRequestUUIDs, ]).pipe( mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), toArray(), diff --git a/src/app/core/data/base/create-data.spec.ts b/src/app/core/data/base/create-data.spec.ts index 0b2e0f39308..1248c5ffe49 100644 --- a/src/app/core/data/base/create-data.spec.ts +++ b/src/app/core/data/base/create-data.spec.ts @@ -5,23 +5,33 @@ * * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { RequestParam } from '../../cache/models/request-param.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceObject } from '../../shared/dspace-object.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { CreateData, CreateDataImpl } from './create-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { RequestParam } from '../../cache/models/request-param.model'; import { RestRequestMethod } from '../rest-request-method'; -import { DSpaceObject } from '../../shared/dspace-object.model'; +import { + CreateData, + CreateDataImpl, +} from './create-data'; /** * Tests whether calls to `CreateData` methods are correctly patched through in a concrete data service that implements it @@ -149,7 +159,7 @@ describe('CreateDataImpl', () => { describe('create', () => { it('should POST the object to the root endpoint with the given parameters and return the remote data', (done) => { const params = [ - new RequestParam('abc', 123), new RequestParam('def', 456) + new RequestParam('abc', 123), new RequestParam('def', 456), ]; buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts index 3ffcd9adf20..13216f8796d 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -5,22 +5,31 @@ * * http://www.dspace.org/license/ */ +import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + map, + take, + takeWhile, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmptyOperator, +} from '../../../shared/empty.util'; +import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getClassForType } from '../../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { CacheableObject } from '../../cache/cacheable-object.model'; -import { 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 { ObjectCacheService } from '../../cache/object-cache.service'; import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; -import { getClassForType } from '../../cache/builders/build-decorators'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; 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'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can create objects. @@ -37,7 +46,7 @@ export interface CreateData { } /** - * A DataService feature to create objects. + * A UpdateDataServiceImpl 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. @@ -95,7 +104,7 @@ export class CreateDataImpl extends BaseDataService) => rd.isLoading, true) + takeWhile((rd: RemoteData) => rd.isLoading, true), ).subscribe((rd: RemoteData) => { if (rd.hasFailed) { this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); diff --git a/src/app/core/data/base/data-service.decorator.spec.ts b/src/app/core/data/base/data-service.decorator.spec.ts deleted file mode 100644 index e09c531a569..00000000000 --- a/src/app/core/data/base/data-service.decorator.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable max-classes-per-file */ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ -import { ResourceType } from '../../shared/resource-type'; -import { BaseDataService } from './base-data.service'; -import { HALDataService } from './hal-data-service.interface'; -import { dataService, getDataServiceFor } from './data-service.decorator'; -import { v4 as uuidv4 } from 'uuid'; - -class TestService extends BaseDataService { -} - -class AnotherTestService implements HALDataService { - public findListByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any { - return undefined; - } - - public findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any { - return undefined; - } -} - -let testType; - -describe('@dataService/getDataServiceFor', () => { - beforeEach(() => { - testType = new ResourceType(`testType-${uuidv4()}`); - }); - - it('should register a resourcetype for a dataservice', () => { - dataService(testType)(TestService); - expect(getDataServiceFor(testType)).toBe(TestService); - }); - - describe(`when the resource type isn't specified`, () => { - it(`should throw an error`, () => { - expect(() => { - dataService(undefined)(TestService); - }).toThrow(); - }); - }); - - describe(`when there already is a registered dataservice for a resourcetype`, () => { - it(`should throw an error`, () => { - dataService(testType)(TestService); - expect(() => { - dataService(testType)(AnotherTestService); - }).toThrow(); - }); - }); -}); diff --git a/src/app/core/data/base/data-service.decorator.ts b/src/app/core/data/base/data-service.decorator.ts deleted file mode 100644 index fbde9bd94f8..00000000000 --- a/src/app/core/data/base/data-service.decorator.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ -import { InjectionToken } from '@angular/core'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { ResourceType } from '../../shared/resource-type'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { HALDataService } from './hal-data-service.interface'; - -export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>>('getDataServiceFor', { - providedIn: 'root', - factory: () => getDataServiceFor, -}); -const dataServiceMap = new Map(); - -/** - * A class decorator to indicate that this class is a data service for a given HAL resource type. - * - * In most cases, a data service should extend {@link BaseDataService}. - * At the very least it must implement {@link HALDataService} in order for it to work with {@link LinkService}. - * - * @param resourceType the resource type the class is a dataservice for - */ -export function dataService(resourceType: ResourceType) { - return (target: GenericConstructor>): void => { - if (hasNoValue(resourceType)) { - throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); - } - const existingDataservice = dataServiceMap.get(resourceType.value); - - if (hasValue(existingDataservice)) { - throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); - } - - dataServiceMap.set(resourceType.value, target); - }; -} - -/** - * Return the dataservice matching the given resource type - * - * @param resourceType the resource type you want the matching dataservice for - */ -export function getDataServiceFor(resourceType: ResourceType): GenericConstructor> { - return dataServiceMap.get(resourceType.value); -} diff --git a/src/app/core/data/base/delete-data.spec.ts b/src/app/core/data/base/delete-data.spec.ts index a076473b0fe..f3fa72a285a 100644 --- a/src/app/core/data/base/delete-data.spec.ts +++ b/src/app/core/data/base/delete-data.spec.ts @@ -5,24 +5,35 @@ * * http://www.dspace.org/license/ */ -import { constructIdEndpointDefault } from './identifiable-data.service'; -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { DeleteData, DeleteDataImpl } from './delete-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { RestRequestMethod } from '../rest-request-method'; +import { + DeleteData, + DeleteDataImpl, +} from './delete-data'; +import { constructIdEndpointDefault } from './identifiable-data.service'; /** * Tests whether calls to `DeleteData` methods are correctly patched through in a concrete data service that implements it @@ -34,7 +45,7 @@ export function testDeleteDataImplementation(serviceFactory: () => DeleteData { @@ -105,13 +116,13 @@ describe('DeleteDataImpl', () => { }, getByHref: () => { /* empty */ - } + }, } as any; notificationsService = {} as NotificationsService; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -198,6 +209,11 @@ describe('DeleteDataImpl', () => { method: RestRequestMethod.DELETE, href: 'some-href?copyVirtualMetadata=a©VirtualMetadata=b©VirtualMetadata=c', })); + + const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1]; + callback(); + expect(service.invalidateByHref).toHaveBeenCalledWith('some-href'); + done(); }); }); diff --git a/src/app/core/data/base/delete-data.ts b/src/app/core/data/base/delete-data.ts index 807d9d838e9..c47a6af8474 100644 --- a/src/app/core/data/base/delete-data.ts +++ b/src/app/core/data/base/delete-data.ts @@ -5,19 +5,26 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { NoContent } from '../../shared/NoContent.model'; import { switchMap } from 'rxjs/operators'; -import { DeleteRequest } from '../request.models'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NoContent } from '../../shared/NoContent.model'; +import { RemoteData } from '../remote-data'; +import { DeleteRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './identifiable-data.service'; export interface DeleteData { /** @@ -68,15 +75,16 @@ export class DeleteDataImpl extends IdentifiableDataS deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { const requestId = this.requestService.generateRequestId(); + let deleteHref: string = href; if (copyVirtualMetadata) { copyVirtualMetadata.forEach((id) => - href += (href.includes('?') ? '&' : '?') + deleteHref += (deleteHref.includes('?') ? '&' : '?') + 'copyVirtualMetadata=' + id, ); } - const request = new DeleteRequest(requestId, href); + const request = new DeleteRequest(requestId, deleteHref); if (hasValue(this.responseMsToLive)) { request.responseMsToLive = this.responseMsToLive; } diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts index 6a73e032d07..f2c48feb76c 100644 --- a/src/app/core/data/base/find-all-data.spec.ts +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -5,24 +5,33 @@ * * http://www.dspace.org/license/ */ -import { FindAllData, FindAllDataImpl } from './find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { 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 { + Observable, + of as observableOf, +} from 'rxjs'; 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 { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { + SortDirection, + SortOptions, +} from '../../cache/models/sort-options.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { EMBED_SEPARATOR } from './base-data.service'; +import { + FindAllData, + FindAllDataImpl, +} from './find-all-data'; /** * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it @@ -143,8 +152,8 @@ describe('FindAllDataImpl', () => { options = {}; (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(endpoint); - }, + expect(value).toBe(endpoint); + }, ); }); diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts index 57884e537e5..0d68689e611 100644 --- a/src/app/core/data/base/find-all-data.ts +++ b/src/app/core/data/base/find-all-data.ts @@ -7,18 +7,23 @@ */ import { Observable } from 'rxjs'; -import { FindListOptions } from '../find-list-options.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, +} from 'rxjs/operators'; + import { isNotEmpty } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that list all of its objects. @@ -42,7 +47,7 @@ export interface FindAllData { } /** - * A DataService feature to list all objects. + * A UpdateDataServiceImpl 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. @@ -87,10 +92,9 @@ export class FindAllDataImpl extends BaseDataService< * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { - let endpoint$: Observable; const args = []; - endpoint$ = this.getBrowseEndpoint(options).pipe( + const endpoint$ = this.getBrowseEndpoint(options).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), distinctUntilChanged(), diff --git a/src/app/core/data/base/hal-data-service.interface.ts b/src/app/core/data/base/hal-data-service.interface.ts index 6959399760c..1ffdffaa7c5 100644 --- a/src/app/core/data/base/hal-data-service.interface.ts +++ b/src/app/core/data/base/hal-data-service.interface.ts @@ -6,11 +6,12 @@ * http://www.dspace.org/license/ */ import { Observable } from 'rxjs'; + import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; +import { HALResource } from '../../shared/hal-resource.model'; import { FindListOptions } from '../find-list-options.model'; import { PaginatedList } from '../paginated-list.model'; -import { HALResource } from '../../shared/hal-resource.model'; +import { RemoteData } from '../remote-data'; /** * An interface defining the minimum functionality needed for a data service to resolve HAL resources. diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts index 11af83ff9f6..9281a5c0eb0 100644 --- a/src/app/core/data/base/identifiable-data.service.spec.ts +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -5,23 +5,24 @@ * * http://www.dspace.org/license/ */ -import { FindListOptions } from '../find-list-options.model'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { IdentifiableDataService } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { EMBED_SEPARATOR } from './base-data.service'; +import { IdentifiableDataService } from './identifiable-data.service'; -const endpoint = 'https://rest.api/core'; +const base = 'https://rest.api/core'; +const endpoint = 'test'; class TestService extends IdentifiableDataService { constructor( @@ -30,11 +31,7 @@ class TestService extends IdentifiableDataService { protected objectCache: ObjectCacheService, protected halService: HALEndpointService, ) { - super(undefined, requestService, rdbService, objectCache, halService); - } - - public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { - return observableOf(endpoint); + super(endpoint, requestService, rdbService, objectCache, halService); } } @@ -51,7 +48,7 @@ describe('IdentifiableDataService', () => { function initTestService(): TestService { requestService = getMockRequestService(); - halService = new HALEndpointServiceStub('url') as any; + halService = new HALEndpointServiceStub(base) as any; rdbService = getMockRemoteDataBuildService(); objectCache = { @@ -63,12 +60,12 @@ describe('IdentifiableDataService', () => { }, getByHref: () => { /* empty */ - } + }, } as any; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -132,7 +129,7 @@ describe('IdentifiableDataService', () => { resourceIdMock, followLink('bundles', { shouldEmbed: false }), followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') + followLink('templateItemOf'), ); expect(result).toEqual(expected); }); @@ -143,4 +140,12 @@ describe('IdentifiableDataService', () => { expect(result).toEqual(expected); }); }); + + describe('invalidateById', () => { + it('should invalidate the correct resource by href', () => { + spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true)); + service.invalidateById('123'); + expect(service.invalidateByHref).toHaveBeenCalledWith(`${base}/${endpoint}/123`); + }); + }); }); diff --git a/src/app/core/data/base/identifiable-data.service.ts b/src/app/core/data/base/identifiable-data.service.ts index 904f925765c..ba368d21ca1 100644 --- a/src/app/core/data/base/identifiable-data.service.ts +++ b/src/app/core/data/base/identifiable-data.service.ts @@ -5,16 +5,21 @@ * * 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 { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Shorthand type for the method to construct an ID endpoint. @@ -80,4 +85,19 @@ export class IdentifiableDataService extends BaseData return this.getEndpoint().pipe( map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); } + + /** + * Invalidate a cached resource by its identifier + * @param resourceID the identifier of the resource to invalidate + */ + invalidateById(resourceID: string): Observable { + const ok$ = this.getIDHrefObs(resourceID).pipe( + take(1), + switchMap((href) => this.invalidateByHref(href)), + ); + + ok$.subscribe(); + + return ok$; + } } diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts index a55b1229b87..03c15199a7c 100644 --- a/src/app/core/data/base/patch-data.spec.ts +++ b/src/app/core/data/base/patch-data.spec.ts @@ -6,28 +6,38 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { RequestService } from '../request.service'; +import { + compare, + Operation, +} from 'fast-json-patch'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceObject } from '../../shared/dspace-object.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { Item } from '../../shared/item.model'; +import { ChangeAnalyzer } from '../change-analyzer'; import { FindListOptions } from '../find-list-options.model'; -import { 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 { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { RestRequestMethod } from '../rest-request-method'; +import { constructIdEndpointDefault } from './identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './patch-data'; /** * Tests whether calls to `PatchData` methods are correctly patched through in a concrete data service that implements it @@ -182,15 +192,15 @@ describe('PatchDataImpl', () => { _links: { self: { href: 'dso-href', - } - } + }, + }, }; const operations = [ Object.assign({ op: 'move', from: '/1', - path: '/5' - }) as Operation + path: '/5', + }) as Operation, ]; it('should send a PatchRequest', () => { @@ -224,12 +234,12 @@ describe('PatchDataImpl', () => { dso = Object.assign(new DSpaceObject(), { _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name1 }] + metadata: [{ key: 'dc.title', value: name1 }], }); dso2 = Object.assign(new DSpaceObject(), { _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name2 }] + metadata: [{ key: 'dc.title', value: name2 }], }); spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index e30c394a347..adcf98ef947 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -5,22 +5,36 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; +import { + find, + map, + mergeMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../shared/operators'; +import { ChangeAnalyzer } from '../change-analyzer'; import { RemoteData } from '../remote-data'; -import { find, map, mergeMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { PatchRequest } from '../request.models'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators'; -import { ChangeAnalyzer } from '../change-analyzer'; import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; import { RestRequestMethod } from '../rest-request-method'; -import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; - +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './identifiable-data.service'; /** * Interface for a data service that can patch and update objects. @@ -54,7 +68,7 @@ export interface PatchData { } /** - * A DataService feature to patch and update objects. + * A UpdateDataServiceImpl 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. diff --git a/src/app/core/data/base/put-data.spec.ts b/src/app/core/data/base/put-data.spec.ts index 6287fe91b13..1430bb31061 100644 --- a/src/app/core/data/base/put-data.spec.ts +++ b/src/app/core/data/base/put-data.spec.ts @@ -6,20 +6,27 @@ * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceObject } from '../../shared/dspace-object.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { 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 { RequestService } from '../request.service'; 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'; +import { + PutData, + PutDataImpl, +} from './put-data'; /** * Tests whether calls to `PutData` methods are correctly patched through in a concrete data service that implements it @@ -127,7 +134,7 @@ describe('PutDataImpl', () => { metadata: { // recognized properties will be serialized ['dc.title']: [ { language: 'en', value: 'some object' }, - ] + ], }, data: [ 1, 2, 3, 4 ], // unrecognized properties won't be serialized _links: { self: { href: selfLink } }, @@ -144,7 +151,7 @@ describe('PutDataImpl', () => { method: RestRequestMethod.PUT, body: { // _links are not serialized uuid: obj.uuid, - metadata: obj.metadata + metadata: obj.metadata, }, })); done(); diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts index bd2a8d29299..e9d2b01eb83 100644 --- a/src/app/core/data/base/put-data.ts +++ b/src/app/core/data/base/put-data.ts @@ -5,18 +5,19 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { 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 { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { GenericConstructor } from '../../shared/generic-constructor'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { PutRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can send PUT requests. @@ -31,7 +32,7 @@ export interface PutData { } /** - * A DataService feature to send PUT requests. + * A UpdateDataServiceImpl 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. @@ -55,7 +56,7 @@ export class PutDataImpl extends BaseDataService i */ put(object: T): Observable> { const requestId = this.requestService.generateRequestId(); - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor).serialize(object); const request = new PutRequest(requestId, object._links.self.href, serializedObject); if (hasValue(this.responseMsToLive)) { diff --git a/src/app/core/data/base/search-data.spec.ts b/src/app/core/data/base/search-data.spec.ts index 31dddeddfce..af9f87bf2cf 100644 --- a/src/app/core/data/base/search-data.spec.ts +++ b/src/app/core/data/base/search-data.spec.ts @@ -5,12 +5,17 @@ * * http://www.dspace.org/license/ */ -import { constructSearchEndpointDefault, SearchData, SearchDataImpl } from './search-data'; -import { FindListOptions } from '../find-list-options.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; import { of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; + import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../find-list-options.model'; +import { + constructSearchEndpointDefault, + SearchData, + SearchDataImpl, +} from './search-data'; /** * Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts index 536d6d6e254..f758affa280 100644 --- a/src/app/core/data/base/search-data.ts +++ b/src/app/core/data/base/search-data.ts @@ -5,19 +5,26 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; -import { hasNoValue, isNotEmpty } from '../../../shared/empty.util'; -import { FindListOptions } from '../find-list-options.model'; +import { + filter, + map, +} from 'rxjs/operators'; + +import { + hasNoValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Shorthand type for method to construct a search endpoint @@ -51,7 +58,7 @@ export interface SearchData { } /** - * A DataService feature to search for objects. + * A UpdateDataServiceImpl 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. @@ -112,10 +119,9 @@ export class SearchDataImpl extends BaseDataService[]): Observable { - let result$: Observable; const args = []; - result$ = this.getSearchEndpoint(searchMethod); + const result$ = this.getSearchEndpoint(searchMethod); return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index 89178f8dd21..b18b61d006e 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -1,26 +1,44 @@ import { TestBed } from '@angular/core/testing'; -import { BitstreamDataService } from './bitstream-data.service'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { ItemMock } from 'src/app/shared/mocks/item.mock'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from 'src/app/shared/remote-data.utils'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { RequestService } from './request.service'; import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { Observable, of as observableOf } from 'rxjs'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; -import { PatchRequest, PutRequest } from './request.models'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; +import { Bundle } from '../shared/bundle.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { BitstreamDataService } from './bitstream-data.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BundleDataService } from './bundle-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import objectContaining = jasmine.objectContaining; import { RemoteData } from './remote-data'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { + PatchRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import objectContaining = jasmine.objectContaining; +import { RestResponse } from '../cache/response.models'; +import { RequestEntry } from './request-entry.model'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -29,39 +47,47 @@ describe('BitstreamDataService', () => { let halService: HALEndpointService; let bitstreamFormatService: BitstreamFormatDataService; let rdbService: RemoteDataBuildService; + let bundleDataService: BundleDataService; const bitstreamFormatHref = 'rest-api/bitstreamformats'; + let responseCacheEntry: RequestEntry; const bitstream1 = Object.assign(new Bitstream(), { id: 'fake-bitstream1', uuid: 'fake-bitstream1', _links: { - self: { href: 'fake-bitstream1-self' } - } + self: { href: 'fake-bitstream1-self' }, + }, }); const bitstream2 = Object.assign(new Bitstream(), { id: 'fake-bitstream2', uuid: 'fake-bitstream2', _links: { - self: { href: 'fake-bitstream2-self' } - } + self: { href: 'fake-bitstream2-self' }, + }, }); const format = Object.assign(new BitstreamFormat(), { id: '2', shortDescription: 'PNG', description: 'Portable Network Graphics', - supportLevel: BitstreamFormatSupportLevel.Known + supportLevel: BitstreamFormatSupportLevel.Known, }); const url = 'fake-bitstream-url'; beforeEach(() => { + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), + getByHref: observableOf(responseCacheEntry), }); requestService = getMockRequestService(); halService = Object.assign(new HALEndpointServiceStub(url)); bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', { - getBrowseEndpoint: observableOf(bitstreamFormatHref) + getBrowseEndpoint: observableOf(bitstreamFormatHref), }); + rdbService = getMockRemoteDataBuildService(); TestBed.configureTestingModule({ @@ -76,6 +102,7 @@ describe('BitstreamDataService', () => { ], }); service = TestBed.inject(BitstreamDataService); + bundleDataService = TestBed.inject(BundleDataService); }); describe('composition', () => { @@ -118,6 +145,32 @@ describe('BitstreamDataService', () => { expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); }); + describe('findPrimaryBitstreamByItemAndName', () => { + it('should return primary bitstream', () => { + const exprected$ = cold('(a|)', { a: bitstream1 } ); + const bundle = Object.assign(new Bundle(), { + primaryBitstream: observableOf(createSuccessfulRemoteDataObject(bitstream1)), + }); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle))); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + + it('should return null if primary bitstream has not be succeeded ', () => { + const exprected$ = cold('(a|)', { a: null } ); + const bundle = Object.assign(new Bundle(), { + primaryBitstream: observableOf(createFailedRemoteDataObject()), + }); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle))); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + + it('should return EMPTY if nothing where found', () => { + const exprected$ = cold('(|)', {} ); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createFailedRemoteDataObject())); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + }); + it('should be able to delete multiple bitstreams', () => { service.removeMultiple([bitstream1, bitstream2]); @@ -132,4 +185,30 @@ describe('BitstreamDataService', () => { expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream2-self'); }); }); + + describe('findByItemHandle', () => { + it('should encode the filename correctly in the search parameters', () => { + const handle = '123456789/1234'; + const sequenceId = '5'; + const filename = 'file with spaces.pdf'; + const searchParams = [ + new RequestParam('handle', handle), + new RequestParam('sequenceId', sequenceId), + new RequestParam('filename', filename), + ]; + const linksToFollow: FollowLinkConfig[] = []; + + spyOn(service as any, 'getSearchByHref').and.callThrough(); + + service.getSearchByHref('byItemHandle', { searchParams }, ...linksToFollow).subscribe((href) => { + expect(service.getSearchByHref).toHaveBeenCalledWith( + 'byItemHandle', + { searchParams }, + ...linksToFollow, + ); + + expect(href).toBe(`${url}/bitstreams/search/byItemHandle?handle=123456789%2F1234&sequenceId=5&filename=file%20with%20spaces.pdf`); + }); + }); + }); }); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index bb4ec281665..2f63bf4e4e3 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,47 +1,74 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { find, map, switchMap, take } from 'rxjs/operators'; +import { + Operation, + RemoveOperation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + EMPTY, + Observable, +} from 'rxjs'; +import { + find, + map, + switchMap, + take, +} from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Bitstream } from '../shared/bitstream.model'; -import { BITSTREAM } from '../shared/bitstream.resource-type'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; import { Bundle } from '../shared/bundle.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { BundleDataService } from './bundle-data.service'; -import { buildPaginatedList, PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { PatchRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { NoContent } from '../shared/NoContent.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; 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 { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BundleDataService } from './bundle-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + PatchRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NoContent } from '../shared/NoContent.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; -import { Operation, RemoveOperation } from 'fast-json-patch'; /** * A service to retrieve {@link Bitstream}s from the REST API */ -@Injectable({ - providedIn: 'root', -}) -@dataService(BITSTREAM) +@Injectable({ providedIn: 'root' }) export class BitstreamDataService extends IdentifiableDataService implements SearchData, PatchData, DeleteData { private searchData: SearchDataImpl; private patchData: PatchDataImpl; @@ -107,7 +134,7 @@ export class BitstreamDataService extends IdentifiableDataService imp } else { return [bundleRD as any]; } - }) + }), ); } @@ -120,10 +147,10 @@ export class BitstreamDataService extends IdentifiableDataService imp const requestId = this.requestService.generateRequestId(); const bitstreamHref$ = this.getBrowseEndpoint().pipe( map((href: string) => `${href}/${bitstream.id}`), - switchMap((href: string) => this.halService.getEndpoint('format', href)) + switchMap((href: string) => this.halService.getEndpoint('format', href)), ); const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe( - map((href: string) => `${href}/${format.id}`) + map((href: string) => `${href}/${format.id}`), ); observableCombineLatest([bitstreamHref$, formatHref$]).pipe( map(([bitstreamHref, formatHref]) => { @@ -134,14 +161,27 @@ export class BitstreamDataService extends IdentifiableDataService imp return new PutRequest(requestId, bitstreamHref, formatHref, options); }), sendRequest(this.requestService), - take(1) + take(1), ).subscribe(() => { - this.requestService.removeByHrefSubstring(bitstream.self + '/format'); + this.deleteFormatCache(bitstream); }); - return this.rdbService.buildFromRequestUUID(requestId); } + private deleteFormatCache(bitstream: Bitstream) { + const bitsreamFormatUrl = bitstream.self + '/format'; + this.requestService.setStaleByHrefSubstring(bitsreamFormatUrl); + // Delete also cache by uuid as the format could be cached also there + this.objectCache.getByHref(bitsreamFormatUrl).pipe(take(1)).subscribe((cachedRequest) => { + if (cachedRequest.requestUUIDs && cachedRequest.requestUUIDs.length > 0){ + const requestUuid = cachedRequest.requestUUIDs[0]; + if (this.requestService.hasByUUID(requestUuid)) { + this.requestService.setStaleByUUID(requestUuid); + } + } + }); + } + /** * Returns an observable of {@link RemoteData} of a {@link Bitstream}, based on a handle and an * optional sequenceId or filename, with a list of {@link FollowLinkConfig}, to automatically @@ -177,7 +217,7 @@ export class BitstreamDataService extends IdentifiableDataService imp const hrefObs = this.getSearchByHref( 'byItemHandle', { searchParams }, - ...linksToFollow + ...linksToFollow, ); return this.findByHref( @@ -201,6 +241,38 @@ export class BitstreamDataService extends IdentifiableDataService imp return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); } + + /** + * + * Make a request to get primary bitstream + * in all current use cases, and having it simplifies this method + * + * @param item the {@link Item} the {@link Bundle} is a part of + * @param bundleName the name of the {@link Bundle} we want to find + * {@link Bitstream}s 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 + * @param options the {@link FindListOptions} for the request + * @return {Observable} + * Return an observable that constains primary bitstream information or null + */ + public findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, options?: FindListOptions): Observable { + return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, options, followLink('primaryBitstream')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (!rd.hasSucceeded) { + return EMPTY; + } + return rd.payload.primaryBitstream.pipe( + getFirstCompletedRemoteData(), + map((rdb: RemoteData) => rdb.hasSucceeded ? rdb.payload : null), + ); + }), + ); + } + /** * Make a new FindListRequest with given search method * 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 15efebe8c72..234326d453b 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -1,21 +1,36 @@ -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { RestResponse } from '../cache/response.models'; -import { Observable, of as observableOf } from 'rxjs'; -import { Action, Store } from '@ngrx/store'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; import { waitForAsync } from '@angular/core/testing'; -import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { + Action, + Store, +} from '@ngrx/store'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction, +} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { RequestEntry } from './request-entry.model'; describe('BitstreamFormatDataService', () => { let service: BitstreamFormatDataService; @@ -31,19 +46,19 @@ describe('BitstreamFormatDataService', () => { const store = { dispatch(action: Action) { // Do Nothing - } + }, } as Store; const requestUUIDs = ['some', 'uuid']; const objectCache = jasmine.createSpyObj('objectCache', { - getByHref: observableOf({ requestUUIDs }) + getByHref: observableOf({ requestUUIDs }), }) as ObjectCacheService; const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: bitstreamFormatsEndpoint }); - } + }, } as HALEndpointService; const notificationsService = {} as NotificationsService; @@ -83,7 +98,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -104,7 +119,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -127,7 +142,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -149,7 +164,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -174,7 +189,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -198,12 +213,12 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); const halService = { getEndpoint(linkPath: string): Observable { return observableOf(bitstreamFormatsEndpoint); - } + }, } as HALEndpointService; service = initTestService(halService); service.clearBitStreamFormatRequests().subscribe(); @@ -222,7 +237,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -245,7 +260,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -268,7 +283,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -289,12 +304,12 @@ describe('BitstreamFormatDataService', () => { getByUUID: hot('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); const halService = { getEndpoint(linkPath: string): Observable { return observableOf(bitstreamFormatsEndpoint); - } + }, } as HALEndpointService; service = initTestService(halService); })); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 01043898158..97b0fa961a9 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,30 +1,50 @@ import { Injectable } from '@angular/core'; -import { createSelector, select, Store } from '@ngrx/store'; +import { + createSelector, + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { + distinctUntilChanged, + map, + tap, +} from 'rxjs/operators'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; + +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction, +} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { coreSelector } from '../core.selectors'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; +import { CoreState } from '../core-state.model'; import { Bitstream } from '../shared/bitstream.model'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteData } from './remote-data'; -import { PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; +import { NoContent } from '../shared/NoContent.model'; import { sendRequest } from '../shared/request.operators'; -import { CoreState } from '../core-state.model'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; -import { NoContent } from '../shared/NoContent.model'; -import { dataService } from './base/data-service.decorator'; +import { RemoteData } from './remote-data'; +import { + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; const bitstreamFormatsStateSelector = createSelector( coreSelector, @@ -38,8 +58,7 @@ const selectedBitstreamFormatSelector = createSelector( /** * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint */ -@Injectable() -@dataService(BITSTREAM_FORMAT) +@Injectable({ providedIn: 'root' }) export class BitstreamFormatDataService extends IdentifiableDataService implements FindAllData, DeleteData { protected linkPath = 'bitstreamformats'; @@ -106,7 +125,7 @@ export class BitstreamFormatDataService extends IdentifiableDataService { return new PostRequest(requestId, endpointURL, bitstreamFormat); }), - sendRequest(this.requestService) + sendRequest(this.requestService), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); @@ -117,7 +136,7 @@ export class BitstreamFormatDataService extends IdentifiableDataService { return this.getBrowseEndpoint().pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) + tap((href: string) => this.requestService.removeByHrefSubstring(href)), ); } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 9fa7239ef7c..53d2ec20fc8 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,9 +1,9 @@ import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; -import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; class TestService extends BrowseResponseParsingService { constructor(protected objectCache: ObjectCacheService) { @@ -26,22 +26,22 @@ describe('BrowseResponseParsingService', () => { describe('', () => { const mockFlatBrowse = { - id: 'title', - browseType: 'flatBrowse', - type: 'browse', - }; + id: 'title', + browseType: 'flatBrowse', + type: 'browse', + }; const mockValueList = { - id: 'author', - browseType: 'valueList', - type: 'browse', - }; + id: 'author', + browseType: 'valueList', + type: 'browse', + }; const mockHierarchicalBrowse = { - id: 'srsc', - browseType: 'hierarchicalBrowse', - type: 'browse', - }; + id: 'srsc', + browseType: 'hierarchicalBrowse', + type: 'browse', + }; it('should deserialize flatBrowses correctly', () => { let deserialized = service.deserialize(mockFlatBrowse); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index a568cdb6176..e01fa17f1fb 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -1,18 +1,17 @@ import { Injectable } from '@angular/core'; -import { ObjectCacheService } from '../cache/object-cache.service'; + import { hasValue } from '../../shared/empty.util'; -import { - HIERARCHICAL_BROWSE_DEFINITION -} from '../shared/hierarchical-browse-definition.resource-type'; -import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; -import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; -import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; -import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Serializer } from '../serializer'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; /** * A ResponseParsingService used to parse a REST API response to a BrowseDefinition object diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index e3ba438f9bd..b2c8be06af1 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -1,19 +1,23 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { compare, Operation } from 'fast-json-patch'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Item } from '../shared/item.model'; -import { ChangeAnalyzer } from './change-analyzer'; +import { + compare, + Operation, +} from 'fast-json-patch'; + import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { BundleDataService } from './bundle-data.service'; -import { HALLink } from '../shared/hal-link.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; -import { Bundle } from '../shared/bundle.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core-state.model'; +import { Bundle } from '../shared/bundle.model'; +import { HALLink } from '../shared/hal-link.model'; +import { Item } from '../shared/item.model'; import { testPatchDataImplementation } from './base/patch-data.spec'; +import { BundleDataService } from './bundle-data.service'; +import { ChangeAnalyzer } from './change-analyzer'; class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { @@ -41,7 +45,7 @@ describe('BundleDataService', () => { bundleHALLink.href = bundleLink; item = new Item(); item._links = { - bundles: bundleHALLink + bundles: bundleHALLink, }; requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; @@ -56,7 +60,7 @@ describe('BundleDataService', () => { }, getObjectBySelfLink: () => { /* empty */ - } + }, } as any; store = {} as Store; return new BundleDataService( @@ -99,30 +103,30 @@ describe('BundleDataService', () => { metadata: { 'dc.title': [ { - value: 'ORIGINAL' - } - ] - } + value: 'ORIGINAL', + }, + ], + }, }), Object.assign(new Bundle(), { id: 'THUMBNAIL_BUNDLE', metadata: { 'dc.title': [ { - value: 'THUMBNAIL' - } - ] - } + value: 'THUMBNAIL', + }, + ], + }, }), Object.assign(new Bundle(), { id: 'EXTRA_BUNDLE', metadata: { 'dc.title': [ { - value: 'EXTRA' - } - ] - } + value: 'EXTRA', + }, + ], + }, }), ]; spyOn(service, 'findAllByItem').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(bundles))); diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 19f0e737069..79f877fadd7 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,36 +1,39 @@ import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; -import { BUNDLE } from '../shared/bundle.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Bitstream } from '../shared/bitstream.model'; import { RequestEntryState } from './request-entry-state.model'; -import { FindListOptions } from './find-list-options.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RestRequestMethod } from './rest-request-method'; -import { Operation } from 'fast-json-patch'; -import { dataService } from './base/data-service.decorator'; /** * A service to retrieve {@link Bundle}s from the REST API */ -@Injectable( - { providedIn: 'root' }, -) -@dataService(BUNDLE) +@Injectable({ providedIn: 'root' }) export class BundleDataService extends IdentifiableDataService implements PatchData { private bitstreamsEndpoint = 'bitstreams'; @@ -75,10 +78,14 @@ export class BundleDataService extends IdentifiableDataService implement * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved + * @param options the {@link FindListOptions} for the request */ // TODO should be implemented rest side - findByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.findAllByItem(item, { elementsPerPage: 9999 }, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + findByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable> { + //Since we filter by bundleName where the pagination options are not indicated we need to load all the possible bundles. + // This is a workaround, in substitution of the previously recursive call with expand + const paginationOptions = options ?? { elementsPerPage: 9999 }; + return this.findAllByItem(item, paginationOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( map((rd: RemoteData>) => { if (hasValue(rd.payload) && hasValue(rd.payload.page)) { const matchingBundle = rd.payload.page.find((bundle: Bundle) => @@ -91,7 +98,7 @@ export class BundleDataService extends IdentifiableDataService implement RequestEntryState.Success, null, matchingBundle, - 200 + 200, ); } else { return new RemoteData( @@ -101,7 +108,7 @@ export class BundleDataService extends IdentifiableDataService implement RequestEntryState.Error, `The bundle with name ${bundleName} was not found.`, null, - 404 + 404, ); } } else { @@ -119,7 +126,7 @@ export class BundleDataService extends IdentifiableDataService implement getBitstreamsEndpoint(bundleId: string, searchOptions?: PaginatedSearchOptions): Observable { return this.getBrowseEndpoint().pipe( switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)), - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href), ); } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 65f8b3ab2cd..431fe941bba 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -1,29 +1,45 @@ -import { CollectionDataService } from './collection-data.service'; -import { RequestService } from './request.service'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; import { TranslateService } from '@ngx-translate/core'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { Observable } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { hasNoValue } from '../../shared/empty.util'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { ContentSourceRequest, UpdateContentSourceRequest } from './request.models'; -import { ContentSource } from '../shared/content-source.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Collection } from '../shared/collection.model'; +import { ContentSource } from '../shared/content-source.model'; import { PageInfo } from '../shared/page-info.model'; -import { buildPaginatedList } from './paginated-list.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { hasNoValue } from '../../shared/empty.util'; import { testCreateDataImplementation } from './base/create-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; import { testFindAllDataImplementation } from './base/find-all-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec'; -import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { CollectionDataService } from './collection-data.service'; +import { buildPaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + ContentSourceRequest, + UpdateContentSourceRequest, +} from './request.models'; +import { RequestService } from './request.service'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -35,7 +51,7 @@ describe('CollectionDataService', () => { let translate: TranslateService; let notificationsService: any; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: any; const mockCollection1: Collection = Object.assign(new Collection(), { @@ -43,9 +59,9 @@ describe('CollectionDataService', () => { name: 'test-collection-1', _links: { self: { - href: 'https://rest.api/collections/test-collection-1-1' - } - } + href: 'https://rest.api/collections/test-collection-1-1', + }, + }, }); const mockCollection2: Collection = Object.assign(new Collection(), { @@ -53,9 +69,9 @@ describe('CollectionDataService', () => { name: 'test-collection-2', _links: { self: { - href: 'https://rest.api/collections/test-collection-2-2' - } - } + href: 'https://rest.api/collections/test-collection-2-2', + }, + }, }); const mockCollection3: Collection = Object.assign(new Collection(), { @@ -63,9 +79,9 @@ describe('CollectionDataService', () => { name: 'test-collection-3', _links: { self: { - href: 'https://rest.api/collections/test-collection-3-3' - } - } + href: 'https://rest.api/collections/test-collection-3-3', + }, + }, }); const queryString = 'test-string'; @@ -138,7 +154,7 @@ describe('CollectionDataService', () => { it('should return a RemoteData> for the getAuthorizedCollection', () => { const result = service.getAuthorizedCollection(queryString); const expected = cold('a|', { - a: paginatedListRD + a: paginatedListRD, }); expect(result).toBeObservable(expected); }); @@ -153,7 +169,7 @@ describe('CollectionDataService', () => { it('should return a RemoteData> for the getAuthorizedCollectionByCommunity', () => { const result = service.getAuthorizedCollectionByCommunity(communityId, queryString); const expected = cold('a|', { - a: paginatedListRD + a: paginatedListRD, }); expect(result).toBeObservable(expected); }); @@ -200,19 +216,17 @@ describe('CollectionDataService', () => { } rdbService = jasmine.createSpyObj('rdbService', { buildList: hot('a|', { - a: paginatedListRD + a: paginatedListRD, }), buildFromRequestUUID: buildResponse$, - buildSingle: buildResponse$ - }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + buildSingle: buildResponse$, }); + objectCache = new ObjectCacheServiceStub(); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate); } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 405b35c1f94..ee6a5677da0 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -2,41 +2,51 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { INotification } from '../../shared/notifications/models/notification.model'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Collection } from '../shared/collection.model'; -import { COLLECTION } from '../shared/collection.resource-type'; +import { Community } from '../shared/community.model'; import { ContentSource } from '../shared/content-source.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { + getAllCompletedRemoteData, + getFirstCompletedRemoteData, +} from '../shared/operators'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { ContentSourceRequest, - UpdateContentSourceRequest + UpdateContentSourceRequest, } from './request.models'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; import { RestRequest } from './rest-request.model'; -import { FindListOptions } from './find-list-options.model'; -import { Community } from '../shared/community.model'; -import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(COLLECTION) +@Injectable({ providedIn: 'root' }) export class CollectionDataService extends ComColDataService { protected errorTitle = 'collection.source.update.notifications.error.title'; protected contentSourceError = 'collection.source.update.notifications.error.content'; @@ -67,17 +77,18 @@ export class CollectionDataService extends ComColDataService { * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved + * @param searchHref The backend search endpoint to use (default to submit) * @return Observable>> * collection list */ - getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const searchHref = 'findSubmitAuthorized'; + getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, searchHref: string = 'findSubmitAuthorized', ...linksToFollow: FollowLinkConfig[]): Observable>> { options = Object.assign({}, options, { - searchParams: [new RequestParam('query', query)] + searchParams: [new RequestParam('query', query)], }); return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** @@ -102,12 +113,13 @@ export class CollectionDataService extends ComColDataService { options = Object.assign({}, options, { searchParams: [ new RequestParam('query', query), - new RequestParam('entityType', entityType) - ] + new RequestParam('entityType', entityType), + ], }); return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** @@ -121,17 +133,18 @@ export class CollectionDataService extends ComColDataService { * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true): Observable>> { const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [ new RequestParam('uuid', communityId), - new RequestParam('query', query) - ] + new RequestParam('query', query), + ], }); return this.searchBy(searchHref, options, reRequestOnStale).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** * Get all collections the user is authorized to submit to, by community and has the metadata @@ -154,15 +167,16 @@ export class CollectionDataService extends ComColDataService { const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType'; const searchParams = [ new RequestParam('uuid', communityId), - new RequestParam('entityType', entityType) + new RequestParam('entityType', entityType), ]; options = Object.assign({}, options, { - searchParams: searchParams + searchParams: searchParams, }); return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending)); + getAllCompletedRemoteData(), + ); } /** @@ -177,9 +191,8 @@ export class CollectionDataService extends ComColDataService { options.elementsPerPage = 1; return this.searchBy(searchHref, options).pipe( - filter((collections: RemoteData>) => !collections.isResponsePending), - take(1), - map((collections: RemoteData>) => collections.payload.totalElements > 0) + getFirstCompletedRemoteData(), + map((collections: RemoteData>) => collections?.payload?.totalElements > 0), ); } @@ -189,7 +202,7 @@ export class CollectionDataService extends ComColDataService { */ getHarvesterEndpoint(collectionId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)), ); } @@ -200,7 +213,7 @@ export class CollectionDataService extends ComColDataService { getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable> { const href$ = this.getHarvesterEndpoint(collectionId).pipe( isNotEmptyOperator(), - take(1) + take(1), ); href$.subscribe((href: string) => { @@ -227,7 +240,7 @@ export class CollectionDataService extends ComColDataService { headers = headers.append('Content-Type', 'application/json'); options.headers = headers; return new UpdateContentSourceRequest(requestId, href, JSON.stringify(serializedContentSource), options); - }) + }), ); // Execute the post/put request @@ -255,7 +268,7 @@ export class CollectionDataService extends ComColDataService { return (response as RemoteData).payload; } return response as INotification; - }) + }), ); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 0f9f0fa7402..e1fe48c0762 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -1,28 +1,37 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { cold } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core-state.model'; +import { Bitstream } from '../shared/bitstream.model'; import { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RequestService } from './request.service'; -import { createFailedRemoteDataObject, createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { BitstreamDataService } from './bitstream-data.service'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; -import { Bitstream } from '../shared/bitstream.model'; -import { testCreateDataImplementation } from './base/create-data.spec'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; -import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { RequestService } from './request.service'; const LINK_NAME = 'test'; @@ -45,7 +54,7 @@ class TestService extends ComColDataService { protected http: HttpClient, protected bitstreamDataService: BitstreamDataService, protected comparator: DSOChangeAnalyzer, - protected linkPath: string + protected linkPath: string, ) { super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } @@ -79,30 +88,30 @@ describe('ComColDataService', () => { const comparator = {} as any; const options = Object.assign(new FindListOptions(), { - scopeID: scopeID + scopeID: scopeID, }); const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; const mockHalService = { - getEndpoint: (linkPath) => observableOf(communitiesEndpoint) + getEndpoint: (linkPath) => observableOf(communitiesEndpoint), }; function initRdbService(): RemoteDataBuildService { return jasmine.createSpyObj('rdbService', { - buildSingle : createFailedRemoteDataObject$('Error', 500) + buildSingle : createFailedRemoteDataObject$('Error', 500), }); } function initBitstreamDataService(): BitstreamDataService { return jasmine.createSpyObj('bitstreamDataService', { - deleteByHref: createSuccessfulRemoteDataObject$({}) + deleteByHref: createSuccessfulRemoteDataObject$({}), }); } function initMockCommunityDataService(): CommunityDataService { return jasmine.createSpyObj('cds', { getEndpoint: cold('--a-', { a: communitiesEndpoint }), - getIDHref: communityEndpoint + getIDHref: communityEndpoint, }); } @@ -112,11 +121,11 @@ describe('ComColDataService', () => { d: { _links: { [LINK_NAME]: { - href: scopedEndpoint - } - } - } - }) + href: scopedEndpoint, + }, + }, + }, + }), }); } @@ -132,7 +141,7 @@ describe('ComColDataService', () => { http, bitstreamDataService, comparator, - LINK_NAME + LINK_NAME, ); } @@ -200,12 +209,12 @@ describe('ComColDataService', () => { communityWithParentHref = { _links: { parentCommunity: { - href: 'topLevel/parentCommunity' - } - } + href: 'topLevel/parentCommunity', + }, + }, } as Community; communityWithoutParentHref = { - _links: {} + _links: {}, } as Community; }); @@ -238,9 +247,9 @@ describe('ComColDataService', () => { id: 'a20da287-e174-466a-9926-f66as300d399', metadata: [{ key: 'dc.title', - value: 'parent community' + value: 'parent community', }], - _links: {} + _links: {}, }); }); it('should refresh a specific cached community when the parent link can be resolved', () => { @@ -262,9 +271,9 @@ describe('ComColDataService', () => { dso = { _links: { logo: { - href: 'logo-href' - } - } + href: 'logo-href', + }, + }, }; }); @@ -291,8 +300,8 @@ describe('ComColDataService', () => { _links: { self: { href: 'logo-href', - } - } + }, + }, }); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index abc9046cd0e..de0d1a31570 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,34 +1,63 @@ -import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { Operation } from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { Community } from '../shared/community.model'; -import { HALLink } from '../shared/hal-link.model'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { Bitstream } from '../shared/bitstream.model'; import { Collection } from '../shared/collection.model'; -import { BitstreamDataService } from './bitstream-data.service'; +import { Community } from '../shared/community.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { HALLink } from '../shared/hal-link.model'; import { NoContent } from '../shared/NoContent.model'; -import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { FindListOptions } from './find-list-options.model'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { RestRequestMethod } from './rest-request-method'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { RequestParam } from '../cache/models/request-param.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { BitstreamDataService } from './bitstream-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { Operation } from 'fast-json-patch'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RestRequestMethod } from './rest-request-method'; export abstract class ComColDataService extends IdentifiableDataService implements CreateData, FindAllData, SearchData, PatchData, DeleteData { private createData: CreateData; @@ -86,7 +115,7 @@ export abstract class ComColDataService extend }), filter((halLink: HALLink) => isNotEmpty(halLink)), map((halLink: HALLink) => halLink.href), - distinctUntilChanged() + distinctUntilChanged(), ); } } @@ -97,7 +126,7 @@ export abstract class ComColDataService extend public findByParent(parentUUID: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getFindByParentHref(parentUUID).pipe( - map((href: string) => this.buildHrefFromFindOptions(href, options)) + map((href: string) => this.buildHrefFromFindOptions(href, options)), ); return this.findListByHref(href$, options, true, true, ...linksToFollow); } @@ -110,7 +139,7 @@ export abstract class ComColDataService extend return this.halService.getEndpoint(this.linkPath).pipe( // We can't use HalLinkService to discover the logo link itself, as objects without a logo // don't have the link, and this method is also used in the createLogo method. - map((href: string) => new URLCombiner(href, id, 'logo').toString()) + map((href: string) => new URLCombiner(href, id, 'logo').toString()), ); } @@ -132,7 +161,7 @@ export abstract class ComColDataService extend } else { return this.bitstreamDataService.deleteByHref(logoRd.payload._links.self.href); } - }) + }), ); } else { return createFailedRemoteDataObject$(`The given object doesn't have a logo`, 400); @@ -148,7 +177,7 @@ export abstract class ComColDataService extend this.findByHref(parentCommunityUrl).pipe( getFirstCompletedRemoteData(), ), - this.halService.getEndpoint('communities/search/top').pipe(take(1)) + this.halService.getEndpoint('communities/search/top').pipe(take(1)), ]).subscribe(([rd, topHref]: [RemoteData, string]) => { if (rd.hasSucceeded && isNotEmpty(rd.payload) && isNotEmpty(rd.payload.id)) { this.requestService.setStaleByHrefSubstring(rd.payload.id); diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index efb6d50e848..ba4feedbb55 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,26 +1,30 @@ import { Injectable } from '@angular/core'; - import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; -import { COMMUNITY } from '../shared/community.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getAllCompletedRemoteData } from '../shared/operators'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { isNotEmpty } from '../../shared/empty.util'; -import { FindListOptions } from './find-list-options.model'; -import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(COMMUNITY) +@Injectable({ providedIn: 'root' }) export class CommunityDataService extends ComColDataService { protected topLinkPath = 'search/top'; @@ -36,6 +40,32 @@ export class CommunityDataService extends ComColDataService { super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } + /** + * Get all communities the user is authorized to submit to + * + * @param query limit the returned community to those with metadata values + * matching the query terms. + * @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>> + * community list + */ + getAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAdminAuthorized'; + options = Object.assign({}, options, { + searchParams: [new RequestParam('query', query)], + }); + + return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + // this method is overridden in order to make it public getEndpoint() { return this.halService.getEndpoint(this.linkPath); @@ -44,14 +74,14 @@ export class CommunityDataService extends ComColDataService { findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.getEndpoint().pipe( map(href => `${href}/search/top`), - switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)) + switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)), ); } protected getFindByParentHref(parentUUID: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( switchMap((communityEndpointHref: string) => - this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)) + this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)), ); } @@ -59,7 +89,7 @@ export class CommunityDataService extends ComColDataService { return this.getEndpoint().pipe( map((endpoint: string) => this.getIDHref(endpoint, options.scopeID)), filter((href: string) => isNotEmpty(href)), - take(1) + take(1), ); } } diff --git a/src/app/core/data/configuration-data.service.spec.ts b/src/app/core/data/configuration-data.service.spec.ts index 7fe69c16e5c..bccfe45da48 100644 --- a/src/app/core/data/configuration-data.service.spec.ts +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -1,12 +1,16 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigurationDataService } from './configuration-data.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { ConfigurationDataService } from './configuration-data.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; describe('ConfigurationDataService', () => { let scheduler: TestScheduler; @@ -18,7 +22,7 @@ describe('ConfigurationDataService', () => { const testObject = { uuid: 'test-property', name: 'test-property', - values: ['value-1', 'value-2'] + values: ['value-1', 'value-2'], } as ConfigurationProperty; const configLink = 'https://rest.api/rest/api/config/properties'; const requestURL = `https://rest.api/rest/api/config/properties/${testObject.name}`; @@ -28,18 +32,18 @@ describe('ConfigurationDataService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: configLink }) + getEndpoint: cold('a', { a: configLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { a: { - payload: testObject - } - }) + payload: testObject, + }, + }), }); objectCache = {} as ObjectCacheService; @@ -70,8 +74,8 @@ describe('ConfigurationDataService', () => { const result = service.findByPropertyName(testObject.name); const expected = cold('a', { a: { - payload: testObject - } + payload: testObject, + }, }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index de044e25e33..bb1bd19ff14 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,18 +1,15 @@ -/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { IdentifiableDataService } from './base/identifiable-data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(CONFIG_PROPERTY) +@Injectable({ providedIn: 'root' }) /** * Data Service responsible for retrieving Configuration properties */ diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts index 066ccf28c9d..4c0fd789fbf 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; + import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ContentSource } from '../shared/content-source.model'; import { MetadataConfig } from '../shared/metadata-config.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a ContentSource object */ diff --git a/src/app/core/data/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index 992a29e4b84..d6aeca7965b 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; + import { RestResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class DebugResponseParsingService implements ResponseParsingService { parse(request: RestRequest, data: RawRestResponse): RestResponse { console.log('request', request, 'data', data); diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 70c45bbc2de..fa08af018a2 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -1,16 +1,19 @@ import { Injectable } from '@angular/core'; -import { compare } from 'fast-json-patch'; -import { Operation } from 'fast-json-patch'; +import { + compare, + Operation, +} from 'fast-json-patch'; + import { getClassForType } from '../cache/builders/build-decorators'; +import { TypedObject } from '../cache/typed-object.model'; import { DSpaceNotNullSerializer } from '../dspace-rest/dspace-not-null.serializer'; import { ChangeAnalyzer } from './change-analyzer'; -import { TypedObject } from '../cache/typed-object.model'; /** * A class to determine what differs between two * CacheableObjects */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DefaultChangeAnalyzer implements ChangeAnalyzer { /** * Compare the metadata of two CacheableObject and return the differences as diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index a621895633b..95e7b5d69f4 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -1,15 +1,19 @@ -import { compare, Operation } from 'fast-json-patch'; -import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; +import { + compare, + Operation, +} from 'fast-json-patch'; +import cloneDeep from 'lodash/cloneDeep'; + import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataMap } from '../shared/metadata.models'; -import cloneDeep from 'lodash/cloneDeep'; +import { ChangeAnalyzer } from './change-analyzer'; /** * A class to determine what differs between two * DSpaceObjects */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DSOChangeAnalyzer implements ChangeAnalyzer { /** diff --git a/src/app/core/data/dso-redirect.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts index 2122dc663a1..b6b72583ea6 100644 --- a/src/app/core/data/dso-redirect.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -1,16 +1,25 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + +import { AppConfig } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DsoRedirectService } from './dso-redirect.service'; -import { GetRequest, IdentifierType } from './request.models'; -import { RequestService } from './request.service'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { Item } from '../shared/item.model'; import { EMBED_SEPARATOR } from './base/base-data.service'; -import { HardRedirectService } from '../services/hard-redirect.service'; +import { DsoRedirectService } from './dso-redirect.service'; +import { + GetRequest, + IdentifierType, +} from './request.models'; +import { RequestService } from './request.service'; describe('DsoRedirectService', () => { let scheduler: TestScheduler; @@ -33,34 +42,35 @@ describe('DsoRedirectService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: pidLink }) + getEndpoint: cold('a', { a: pidLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); remoteData = createSuccessfulRemoteDataObject(Object.assign(new Item(), { type: 'item', - uuid: '123456789' + uuid: '123456789', })); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { - a: remoteData - }) + a: remoteData, + }), }); redirectService = jasmine.createSpyObj('redirectService', { - redirect: {} + redirect: {}, }); service = new DsoRedirectService( + environment as AppConfig, requestService, rdbService, objectCache, halService, - redirectService + redirectService, ); }); @@ -107,7 +117,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/items/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/items/${remoteData.payload.uuid}`, 301); }); it('should navigate to entities route with the corresponding entity type', () => { remoteData.payload.type = 'item'; @@ -115,8 +125,8 @@ describe('DsoRedirectService', () => { 'dspace.entity.type': [ { language: 'en_US', - value: 'Publication' - } + value: 'Publication', + }, ], }; const redir = service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE); @@ -124,7 +134,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/entities/publication/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/entities/publication/${remoteData.payload.uuid}`, 301); }); it('should navigate to collections route', () => { @@ -133,7 +143,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/collections/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/collections/${remoteData.payload.uuid}`, 301); }); it('should navigate to communities route', () => { @@ -142,7 +152,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/communities/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/communities/${remoteData.payload.uuid}`, 301); }); }); diff --git a/src/app/core/data/dso-redirect.service.ts b/src/app/core/data/dso-redirect.service.ts index a27d1fb11f3..28628a62464 100644 --- a/src/app/core/data/dso-redirect.service.ts +++ b/src/app/core/data/dso-redirect.service.ts @@ -6,21 +6,29 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { Injectable } from '@angular/core'; +import { + Inject, + Injectable, +} from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; + +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; +import { getDSORoute } from '../../app-routing-paths'; import { hasValue } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; import { RemoteData } from './remote-data'; import { IdentifierType } from './request.models'; import { RequestService } from './request.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { getDSORoute } from '../../app-routing-paths'; -import { HardRedirectService } from '../services/hard-redirect.service'; const ID_ENDPOINT = 'pid'; const UUID_ENDPOINT = 'dso'; @@ -42,7 +50,7 @@ class DsoByIdOrUUIDDataService extends IdentifiableDataService { // interpolate id/uuid as query parameter (endpoint: string, resourceID: string): string => { return endpoint.replace(/{\?id}/, `?id=${resourceID}`) - .replace(/{\?uuid}/, `?uuid=${resourceID}`); + .replace(/{\?uuid}/, `?uuid=${resourceID}`); }, ); } @@ -65,16 +73,17 @@ class DsoByIdOrUUIDDataService extends IdentifiableDataService { * A service to handle redirects from identifier paths to DSO path * e.g.: redirect from /handle/... to /items/... */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DsoRedirectService { private dataService: DsoByIdOrUUIDDataService; constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - private hardRedirectService: HardRedirectService + private hardRedirectService: HardRedirectService, ) { this.dataService = new DsoByIdOrUUIDDataService(requestService, rdbService, objectCache, halService); } @@ -82,7 +91,7 @@ export class DsoRedirectService { /** * Redirect to a DSpaceObject's path using the given identifier type and ID. * This is used to redirect paths like "/handle/[prefix]/[suffix]" to the object's path (e.g. /items/[uuid]). - * See LookupGuard for more examples. + * See lookupGuard for more examples. * * @param id the identifier of the object to retrieve * @param identifierType the type of the given identifier (defaults to UUID) @@ -95,14 +104,14 @@ export class DsoRedirectService { if (response.hasSucceeded) { const dso = response.payload; if (hasValue(dso.uuid)) { - let newRoute = getDSORoute(dso); + const newRoute = getDSORoute(dso); if (hasValue(newRoute)) { // Use a "301 Moved Permanently" redirect for SEO purposes - this.hardRedirectService.redirect(newRoute, 301); + this.hardRedirectService.redirect(this.appConfig.ui.nameSpace.replace(/\/$/, '') + newRoute, 301); } } } - }) + }), ); } } diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 74117e79d35..5cabba29eb2 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -1,20 +1,25 @@ import { Injectable } from '@angular/core'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { + DSOSuccessResponse, + RestResponse, +} from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; - -import { ResponseParsingService } from './parsing.service'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; /** * @deprecated use DspaceRestResponseParsingService for new code, this is only left to support a * few legacy use cases, and should get removed eventually */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = true; diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 0f167ea47e2..1e4809ac4bc 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -1,12 +1,16 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DSpaceObjectDataService } from './dspace-object-data.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { DSpaceObjectDataService } from './dspace-object-data.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -16,7 +20,7 @@ describe('DSpaceObjectDataService', () => { let rdbService: RemoteDataBuildService; let objectCache: ObjectCacheService; const testObject = { - uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746' + uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746', } as DSpaceObject; const dsoLink = 'https://rest.api/rest/api/dso/find{?uuid}'; const requestURL = `https://rest.api/rest/api/dso/find?uuid=${testObject.uuid}`; @@ -26,18 +30,18 @@ describe('DSpaceObjectDataService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: dsoLink }) + getEndpoint: cold('a', { a: dsoLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { a: { - payload: testObject - } - }) + payload: testObject, + }, + }), }); objectCache = {} as ObjectCacheService; @@ -68,8 +72,8 @@ describe('DSpaceObjectDataService', () => { const result = service.findById(testObject.uuid); const expected = cold('a', { a: { - payload: testObject - } + payload: testObject, + }, }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 2ad024133c7..bdebbd0582c 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,15 +1,13 @@ import { Injectable } from '@angular/core'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; +import { RequestService } from './request.service'; -@Injectable() -@dataService(DSPACE_OBJECT) +@Injectable({ providedIn: 'root' }) export class DSpaceObjectDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/data/dspace-rest-response-parsing.service.ts b/src/app/core/data/dspace-rest-response-parsing.service.ts index 500afc4aff6..1cd286427f3 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -1,23 +1,34 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { Injectable } from '@angular/core'; + import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { getClassForType } from '../cache/builders/build-decorators'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ParsedResponse } from '../cache/response.models'; +import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { + getEmbedSizeParams, + getUrlWithoutEmbedParams, +} from '../index/index.selectors'; +import { Serializer } from '../serializer'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; -import { ParsedResponse } from '../cache/response.models'; -import { RestRequestMethod } from './rest-request-method'; -import { getUrlWithoutEmbedParams, getEmbedSizeParams } from '../index/index.selectors'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { PageInfo } from '../shared/page-info.model'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; /** @@ -109,6 +120,13 @@ export class DspaceRestResponseParsingService implements ResponseParsingService if (hasValue(match)) { embedAltUrl = new URLCombiner(embedAltUrl, `?size=${match.size}`).toString(); } + if (data._embedded[property] == null) { + // Embedded object is null, meaning it exists (not undefined), but had an empty response (204) -> cache it as null + this.addToObjectCache(null, request, data, embedAltUrl); + } else if (!isCacheableObject(data._embedded[property])) { + // Embedded object exists, but doesn't contain a self link -> cache it using the alternative link instead + this.objectCache.add(data._embedded[property], hasValue(request.responseMsToLive) ? request.responseMsToLive : environment.cache.msToLive.default, request.uuid, embedAltUrl); + } this.process(data._embedded[property], request, embedAltUrl); }); } @@ -144,8 +162,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService console.warn(`The response for '${request.href}' doesn't have a self link. This could mean there's an issue with the REST endpoint`); response.payload._links = Object.assign({}, response.payload._links, { self: { - href: urlWithoutEmbedParams - } + href: urlWithoutEmbedParams, + }, }); } else { @@ -155,8 +173,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService console.warn(`The response for '${urlWithoutEmbedParams}' has the self link '${response.payload._links.self.href}'. These don't match. This could mean there's an issue with the REST endpoint`); response.payload._links = Object.assign({}, response.payload._links, { self: { - href: urlWithoutEmbedParams - } + href: urlWithoutEmbedParams, + }, }); } } @@ -184,8 +202,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService protected processArray(data: any, request: RestRequest): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, request)]; - } + array = [...array, this.process(datum, request)]; + }, ); return array; } @@ -226,12 +244,12 @@ export class DspaceRestResponseParsingService implements ResponseParsingService * @param alternativeURL an alternative url that can be used to retrieve the object */ addToObjectCache(co: CacheableObject, request: RestRequest, data: any, alternativeURL?: string): void { - if (!isCacheableObject(co)) { + if (hasValue(co) && !isCacheableObject(co)) { const type = hasValue(data) && hasValue(data.type) ? data.type : 'object'; let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); @@ -240,7 +258,7 @@ export class DspaceRestResponseParsingService implements ResponseParsingService return; } - if (alternativeURL === co._links.self.href) { + if (hasValue(co) && alternativeURL === co._links.self.href) { alternativeURL = undefined; } diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index 728714876c4..c7dd40b98bd 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; -import { - DspaceRestResponseParsingService, - isCacheableObject -} from './dspace-rest-response-parsing.service'; +import { environment } from '../../../environments/environment'; import { hasValue } from '../../shared/empty.util'; import { getClassForType } from '../cache/builders/build-decorators'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; import { ParsedResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { environment } from '../../../environments/environment'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { + DspaceRestResponseParsingService, + isCacheableObject, +} from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; /** @@ -20,7 +20,7 @@ import { RestRequest } from './rest-request.model'; * * When all endpoints are properly typed, it can be removed. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class EndpointMapResponseParsingService extends DspaceRestResponseParsingService { /** @@ -56,7 +56,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing } catch (e) { console.warn(`Couldn't parse endpoint request at ${request.href}`); return new ParsedResponse(response.statusCode, undefined, { - _links: response.payload._links + _links: response.payload._links, }); } } @@ -101,7 +101,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/entity-type-data.service.ts b/src/app/core/data/entity-type-data.service.ts index 4020ff638dd..d47fadce172 100644 --- a/src/app/core/data/entity-type-data.service.ts +++ b/src/app/core/data/entity-type-data.service.ts @@ -1,26 +1,41 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemType } from '../shared/item-relationships/item-type.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { + getAllCompletedRemoteData, + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { BaseDataService } from './base/base-data.service'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; -import { ItemType } from '../shared/item-relationships/item-type.model'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { RelationshipTypeDataService } from './relationship-type-data.service'; -import { FindListOptions } from './find-list-options.model'; -import { BaseDataService } from './base/base-data.service'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Service handling all ItemType requests */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class EntityTypeDataService extends BaseDataService implements FindAllData, SearchData { private findAllData: FindAllData; private searchData: SearchDataImpl; @@ -48,7 +63,7 @@ export class EntityTypeDataService extends BaseDataService implements */ getRelationshipTypesEndpoint(entityTypeId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)) + switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)), ); } @@ -74,8 +89,7 @@ export class EntityTypeDataService extends BaseDataService implements getAllAuthorizedRelationshipType(options: FindListOptions = {}): Observable>> { const searchHref = 'findAllByAuthorizedCollection'; - return this.searchBy(searchHref, options).pipe( - filter((type: RemoteData>) => !type.isResponsePending)); + return this.searchBy(searchHref, options).pipe(getAllCompletedRemoteData()); } /** @@ -84,7 +98,7 @@ export class EntityTypeDataService extends BaseDataService implements hasMoreThanOneAuthorized(): Observable { const findListOptions: FindListOptions = { elementsPerPage: 2, - currentPage: 1 + currentPage: 1, }; return this.getAllAuthorizedRelationshipType(findListOptions).pipe( map((result: RemoteData>) => { @@ -95,7 +109,7 @@ export class EntityTypeDataService extends BaseDataService implements output = false; } return output; - }) + }), ); } @@ -108,8 +122,7 @@ export class EntityTypeDataService extends BaseDataService implements getAllAuthorizedRelationshipTypeImport(options: FindListOptions = {}): Observable>> { const searchHref = 'findAllByAuthorizedExternalSource'; - return this.searchBy(searchHref, options).pipe( - filter((type: RemoteData>) => !type.isResponsePending)); + return this.searchBy(searchHref, options).pipe(getAllCompletedRemoteData()); } /** @@ -118,18 +131,11 @@ export class EntityTypeDataService extends BaseDataService implements hasMoreThanOneAuthorizedImport(): Observable { const findListOptions: FindListOptions = { elementsPerPage: 2, - currentPage: 1 + currentPage: 1, }; return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( - map((result: RemoteData>) => { - let output: boolean; - if (result.payload) { - output = ( result.payload.page.length > 1 ); - } else { - output = false; - } - return output; - }) + take(1), + map((result: RemoteData>) => result?.payload?.totalElements > 1), ); } diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index afd49271036..a60cef121a8 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -1,16 +1,17 @@ -import { RequestService } from './request.service'; -import { EpersonRegistrationService } from './eperson-registration.service'; -import { RestResponse } from '../cache/response.models'; +import { HttpHeaders } from '@angular/common/http'; import { cold } from 'jasmine-marbles'; -import { PostRequest } from './request.models'; -import { Registration } from '../shared/registration.model'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { RequestEntry } from './request-entry.model'; -import { HttpHeaders } from '@angular/common/http'; + +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RestResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Registration } from '../shared/registration.model'; +import { EpersonRegistrationService } from './eperson-registration.service'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; describe('EpersonRegistrationService', () => { let testScheduler; @@ -44,7 +45,7 @@ describe('EpersonRegistrationService', () => { generateRequestId: 'request-id', send: {}, getByUUID: cold('a', - { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }) + { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }), }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: observableOf(rd), @@ -53,7 +54,7 @@ describe('EpersonRegistrationService', () => { service = new EpersonRegistrationService( requestService, rdbService, - halService + halService, ); }); @@ -111,9 +112,9 @@ describe('EpersonRegistrationService', () => { payload: Object.assign(new Registration(), { email: registrationWithUser.email, token: 'test-token', - user: registrationWithUser.user - }) - }) + user: registrationWithUser.user, + }), + }), })); }); @@ -128,10 +129,10 @@ describe('EpersonRegistrationService', () => { jasmine.objectContaining({ uuid: 'request-id', method: 'GET', href: 'rest-url/registrations/search/findByToken?token=test-token', - }), true + }), true, ); expectObservable(rdbService.buildSingle.calls.argsFor(0)[0]).toBe('(a|)', { - a: 'rest-url/registrations/search/findByToken?token=test-token' + a: 'rest-url/registrations/search/findByToken?token=test-token', }); }); }); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 499d05af380..90a3fab83a9 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -1,20 +1,33 @@ +import { + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { GetRequest, PostRequest } from './request.models'; import { Observable } from 'rxjs'; -import { filter, find, map } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { Registration } from '../shared/registration.model'; +import { + filter, + find, + map, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { Registration } from '../shared/registration.model'; import { ResponseParsingService } from './parsing.service'; -import { 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'; -import { HttpParams } from '@angular/common/http'; +import { + GetRequest, + PostRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -81,11 +94,11 @@ export class EpersonRegistrationService { map((href: string) => { const request = new PostRequest(requestId, href, registration, options); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ); } @@ -105,7 +118,7 @@ export class EpersonRegistrationService { Object.assign(request, { getResponseParser(): GenericConstructor { return RegistrationResponseParsingService; - } + }, }); this.requestService.send(request, true); }); @@ -117,7 +130,7 @@ export class EpersonRegistrationService { } else { return rd; } - }) + }), ); } diff --git a/src/app/core/data/external-source-data.service.spec.ts b/src/app/core/data/external-source-data.service.spec.ts index 723d7f9bed6..5e643cc5491 100644 --- a/src/app/core/data/external-source-data.service.spec.ts +++ b/src/app/core/data/external-source-data.service.spec.ts @@ -1,11 +1,12 @@ -import { ExternalSourceDataService } from './external-source-data.service'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { of as observableOf } from 'rxjs'; -import { GetRequest } from './request.models'; import { testSearchDataImplementation } from './base/search-data.spec'; -import { take } from 'rxjs/operators'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { GetRequest } from './request.models'; describe('ExternalSourceService', () => { let service: ExternalSourceDataService; @@ -22,10 +23,10 @@ describe('ExternalSourceService', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0001' - } - ] - } + value: 'https://orcid.org/0001-0001-0001-0001', + }, + ], + }, }), Object.assign(new ExternalSourceEntry(), { id: '0001-0001-0001-0002', @@ -34,20 +35,20 @@ describe('ExternalSourceService', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0002' - } - ] - } - }) + value: 'https://orcid.org/0001-0001-0001-0002', + }, + ], + }, + }), ]; function init() { requestService = jasmine.createSpyObj('requestService', { generateRequestId: 'request-uuid', - send: {} + send: {}, }); rdbService = jasmine.createSpyObj('rdbService', { - buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) + buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)), }); halService = jasmine.createSpyObj('halService', { getEndpoint: observableOf('external-sources-REST-endpoint'), @@ -96,12 +97,6 @@ describe('ExternalSourceService', () => { result.pipe(take(1)).subscribe(); expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), false); }); - - it('should return the entries', () => { - result.subscribe((resultRD) => { - expect(resultRD.payload.page).toBe(entries); - }); - }); }); }); }); diff --git a/src/app/core/data/external-source-data.service.ts b/src/app/core/data/external-source-data.service.ts index 02c5e4a53cc..e7f123dd18d 100644 --- a/src/app/core/data/external-source-data.service.ts +++ b/src/app/core/data/external-source-data.service.ts @@ -1,25 +1,37 @@ import { Injectable } from '@angular/core'; -import { ExternalSource } from '../shared/external-source.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; +import { + distinctUntilChanged, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { 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 { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { FindListOptions } from './find-list-options.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ExternalSource } from '../shared/external-source.model'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { SearchData, SearchDataImpl } from './base/search-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service handling all external source requests */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ExternalSourceDataService extends IdentifiableDataService implements SearchData { private searchData: SearchData; @@ -50,7 +62,7 @@ export class ExternalSourceDataService extends IdentifiableDataService { return this.getBrowseEndpoint().pipe( map((href) => this.getIDHref(href, externalSourceId)), - switchMap((href) => this.halService.getEndpoint('entries', href)) + switchMap((href) => this.halService.getEndpoint('entries', href)), ); } @@ -78,7 +90,7 @@ export class ExternalSourceDataService extends IdentifiableDataService { return this.findListByHref(href$, undefined, !hasCachedErrorResponse, reRequestOnStale, ...linksToFollow as any); - }) + }), ) as any; } diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 3e4493c32bf..4ae22c34d87 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; + +import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model'; import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; -import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class FacetConfigResponseParsingService extends DspaceRestResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse { @@ -16,19 +17,19 @@ export class FacetConfigResponseParsingService extends DspaceRestResponseParsing const filters = serializer.deserializeArray(config); const _links = { - self: data.payload._links.self + self: data.payload._links.self, }; // fill in the missing links section filters.forEach((filterConfig: SearchFilterConfig) => { _links[filterConfig.name] = { - href: filterConfig._links.self.href + href: filterConfig._links.self.href, }; }); const facetConfigResponse = Object.assign(new FacetConfigResponse(), { filters, - _links + _links, }); this.addToObjectCache(facetConfigResponse, request, data); diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 0911ed50734..5cd24770d8b 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@angular/core'; + import { FacetValue } from '../../shared/search/models/facet-value.model'; +import { FacetValues } from '../../shared/search/models/facet-values.model'; import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { FacetValues } from '../../shared/search/models/facet-values.model'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class FacetValueResponseParsingService extends DspaceRestResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse { const payload = data.payload; diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index ae44d590a4c..4ada344bebb 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -1,18 +1,26 @@ -import { AuthorizationDataService } from './authorization-data.service'; -import { SiteDataService } from '../site-data.service'; -import { Site } from '../../shared/site.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { FeatureID } from './feature-id'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; + import { hasValue } from '../../../shared/empty.util'; +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RequestParam } from '../../cache/models/request-param.model'; +import { EPerson } from '../../eperson/models/eperson.model'; import { Authorization } from '../../shared/authorization.model'; -import { 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 { Site } from '../../shared/site.model'; import { testSearchDataImplementation } from '../base/search-data.spec'; -import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { FindListOptions } from '../find-list-options.model'; +import { SiteDataService } from '../site-data.service'; +import { AuthorizationDataService } from './authorization-data.service'; +import { FeatureID } from './feature-id'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; @@ -23,19 +31,19 @@ describe('AuthorizationDataService', () => { let ePerson: EPerson; const requestService = jasmine.createSpyObj('requestService', { - setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring') + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring'), }); function init() { site = Object.assign(new Site(), { id: 'test-site', _links: { - self: { href: 'test-site-href' } - } + self: { href: 'test-site-href' }, + }, }); ePerson = Object.assign(new EPerson(), { id: 'test-eperson', - uuid: 'test-eperson' + uuid: 'test-eperson', }); siteService = jasmine.createSpyObj('siteService', { find: observableOf(site), @@ -64,7 +72,7 @@ describe('AuthorizationDataService', () => { const ePersonUuid = 'fake-eperson-uuid'; function createExpected(providedObjectUrl: string, providedEPersonUuid?: string, providedFeatureId?: FeatureID): FindListOptions { - const searchParams = [new RequestParam('uri', providedObjectUrl)]; + const searchParams = [new RequestParam('uri', providedObjectUrl, false)]; if (hasValue(providedFeatureId)) { searchParams.push(new RequestParam('feature', providedFeatureId)); } @@ -157,26 +165,26 @@ describe('AuthorizationDataService', () => { const validPayload = [ Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'invalid-feature' - })) + id: 'invalid-feature', + })), }), Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: featureID - })) - }) + id: featureID, + })), + }), ]; const invalidPayload = [ Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'invalid-feature' - })) + id: 'invalid-feature', + })), }), Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'another-invalid-feature' - })) - }) + id: 'another-invalid-feature', + })), + }), ]; const emptyPayload = []; diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index c43d335234b..e5efbae671c 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -1,32 +1,47 @@ -import { Observable, of as observableOf } from 'rxjs'; import { Injectable } from '@angular/core'; -import { AUTHORIZATION } from '../../shared/authorization.resource-type'; -import { Authorization } from '../../shared/authorization.model'; -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + catchError, + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { RequestParam } from '../../cache/models/request-param.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Authorization } from '../../shared/authorization.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { SiteDataService } from '../site-data.service'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { BaseDataService } from '../base/base-data.service'; +import { + SearchData, + SearchDataImpl, +} from '../base/search-data'; +import { FindListOptions } from '../find-list-options.model'; import { PaginatedList } from '../paginated-list.model'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { RequestParam } from '../../cache/models/request-param.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { SiteDataService } from '../site-data.service'; import { AuthorizationSearchParams } from './authorization-search-params'; import { oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; -import { FindListOptions } from '../find-list-options.model'; -import { BaseDataService } from '../base/base-data.service'; -import { SearchData, SearchDataImpl } from '../base/search-data'; -import { dataService } from '../base/data-service.decorator'; /** * A service to retrieve {@link Authorization}s from the REST API */ -@Injectable() -@dataService(AUTHORIZATION) +@Injectable({ providedIn: 'root' }) export class AuthorizationDataService extends BaseDataService implements SearchData { protected linkPath = 'authorizations'; protected searchByObjectPath = 'object'; @@ -74,8 +89,8 @@ export class AuthorizationDataService extends BaseDataService imp return []; } }), - catchError(() => observableOf(false)), - oneAuthorizationMatchesFeature(featureId) + catchError(() => observableOf([])), + oneAuthorizationMatchesFeature(featureId), ); } @@ -100,7 +115,7 @@ export class AuthorizationDataService extends BaseDataService imp switchMap((url) => { if (hasNoValue(url)) { return this.siteService.find().pipe( - map((site) => site.self) + map((site) => site.self), ); } else { return observableOf(url); @@ -112,7 +127,7 @@ export class AuthorizationDataService extends BaseDataService imp map((url: string) => new AuthorizationSearchParams(url, ePersonUuid, featureId)), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - }) + }), ); this.addDependency(out$, objectUrl$); @@ -132,7 +147,8 @@ export class AuthorizationDataService extends BaseDataService imp if (isNotEmpty(options.searchParams)) { params = [...options.searchParams]; } - params.push(new RequestParam('uri', objectUrl)); + // TODO fix encode the uri parameter in the self link in the backend and set encodeValue to true afterwards + params.push(new RequestParam('uri', objectUrl, false)); if (hasValue(featureId)) { params.push(new RequestParam('feature', featureId)); } diff --git a/src/app/core/data/feature-authorization/authorization-utils.ts b/src/app/core/data/feature-authorization/authorization-utils.ts index d1b65f61235..cd4bc452a5e 100644 --- a/src/app/core/data/feature-authorization/authorization-utils.ts +++ b/src/app/core/data/feature-authorization/authorization-utils.ts @@ -1,13 +1,25 @@ -import { map, switchMap } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { AuthorizationSearchParams } from './authorization-search-params'; -import { SiteDataService } from '../site-data.service'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { AuthService } from '../../auth/auth.service'; import { Authorization } from '../../shared/authorization.model'; import { Feature } from '../../shared/feature.model'; -import { FeatureID } from './feature-id'; import { getFirstSucceededRemoteDataPayload } from '../../shared/operators'; +import { SiteDataService } from '../site-data.service'; +import { AuthorizationSearchParams } from './authorization-search-params'; +import { FeatureID } from './feature-id'; /** * Operator accepting {@link AuthorizationSearchParams} and adding the current {@link Site}'s selflink to the parameter's @@ -20,12 +32,12 @@ export const addSiteObjectUrlIfEmpty = (siteService: SiteDataService) => switchMap((params: AuthorizationSearchParams) => { if (hasNoValue(params.objectUrl)) { return siteService.find().pipe( - map((site) => Object.assign({}, params, { objectUrl: site.self })) + map((site) => Object.assign({}, params, { objectUrl: site.self })), ); } else { return observableOf(params); } - }) + }), ); /** @@ -42,17 +54,17 @@ export const addAuthenticatedUserUuidIfEmpty = (authService: AuthService) => switchMap((authenticated) => { if (authenticated) { return authService.getAuthenticatedUserFromStore().pipe( - map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid })) + map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid })), ); } else { return observableOf(params); } - }) + }), ); } else { return observableOf(params); } - }) + }), ); /** @@ -68,16 +80,16 @@ export const oneAuthorizationMatchesFeature = (featureID: FeatureID) => source.pipe( switchMap((authorizations: Authorization[]) => { if (isNotEmpty(authorizations)) { - return observableCombineLatest( + return observableCombineLatest([ ...authorizations .filter((authorization: Authorization) => hasValue(authorization.feature)) .map((authorization: Authorization) => authorization.feature.pipe( - getFirstSucceededRemoteDataPayload() - )) - ); + getFirstSucceededRemoteDataPayload(), + )), + ]); } else { return observableOf([]); } }), - map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID).length > 0) + map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID.valueOf()).length > 0), ); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts index b41a322cb62..1b1b4a9d6ca 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts @@ -1,27 +1,13 @@ -import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + import { FeatureID } from '../feature-id'; +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user * isn't a Collection administrator + * Check group management rights */ -@Injectable({ - providedIn: 'root' -}) -export class CollectionAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } - - /** - * Check group management rights - */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.IsCollectionAdmin); - } -} +export const collectionAdministratorGuard: CanActivateFn = + singleFeatureAuthorizationGuard(() => observableOf(FeatureID.IsCollectionAdmin)); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts index 2ab77a00cc4..6d7dac314e3 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts @@ -1,27 +1,13 @@ -import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + import { FeatureID } from '../feature-id'; +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user * isn't a Community administrator + * Check group management rights */ -@Injectable({ - providedIn: 'root' -}) -export class CommunityAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } - - /** - * Check group management rights - */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.IsCommunityAdmin); - } -} +export const communityAdministratorGuard: CanActivateFn = + singleFeatureAuthorizationGuard(() => observableOf(FeatureID.IsCommunityAdmin)); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts index 6c1f330c695..18292bb943b 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts @@ -1,77 +1,82 @@ -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { Observable, of as observableOf } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { + ResolveFn, + Router, + UrlTree, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { AuthService } from '../../../auth/auth.service'; import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { AuthService } from '../../../auth/auth.service'; +import { dsoPageSingleFeatureGuard } from './dso-page-single-feature.guard'; +import { + defaultDSOGetObjectUrl, + getRouteWithDSOId, +} from './dso-page-some-feature.guard'; -/** - * Test implementation of abstract class DsoPageSingleFeatureGuard - */ -class DsoPageSingleFeatureGuardImpl extends DsoPageSingleFeatureGuard { - constructor(protected resolver: Resolve>, - protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService, - protected featureID: FeatureID) { - super(resolver, authorizationService, router, authService); - } - - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.featureID); - } -} describe('DsoPageSingleFeatureGuard', () => { - let guard: DsoPageSingleFeatureGuard; let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; - let resolver: Resolve>; + let resolver: ResolveFn>; let object: DSpaceObject; let route; let parentRoute; + let featureId: FeatureID; + function init() { object = { - self: 'test-selflink' + self: 'test-selflink', } as DSpaceObject; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} - }); - resolver = jasmine.createSpyObj('resolver', { - resolve: createSuccessfulRemoteDataObject$(object) + parseUrl: {}, }); + resolver = () => createSuccessfulRemoteDataObject$(object); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); parentRoute = { params: { - id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0' - } + id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0', + }, }; route = { params: { }, - parent: parentRoute + parent: parentRoute, }; - guard = new DsoPageSingleFeatureGuardImpl(resolver, authorizationService, router, authService, undefined); + + featureId = FeatureID.LoginOnBehalfOf; + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + ], + }); } beforeEach(() => { init(); }); - describe('getObjectUrl', () => { + describe('defaultDSOGetObjectUrl', () => { it('should return the resolved object\'s selflink', (done) => { - guard.getObjectUrl(route, undefined).subscribe((selflink) => { + defaultDSOGetObjectUrl(resolver)(route, undefined).subscribe((selflink) => { expect(selflink).toEqual(object.self); done(); }); @@ -80,8 +85,23 @@ describe('DsoPageSingleFeatureGuard', () => { describe('getRouteWithDSOId', () => { it('should return the route that has the UUID of the DSO', () => { - const foundRoute = (guard as any).getRouteWithDSOId(route); + const foundRoute = getRouteWithDSOId(route); expect(foundRoute).toBe(parentRoute); }); }); + + describe('dsoPageSingleFeatureGuard', () => { + it('should call authorizationService.isAuthenticated with the appropriate arguments', (done) => { + const result$ = TestBed.runInInjectionContext(() => { + return dsoPageSingleFeatureGuard( + () => resolver, () => observableOf(featureId), + )(route, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe(() => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, object.self, undefined); + done(); + }); + }); + }); }); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts index 3fc90f90696..5073a386532 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts @@ -1,27 +1,27 @@ +import { + ActivatedRouteSnapshot, + CanActivateFn, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../../remote-data'; import { FeatureID } from '../feature-id'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { dsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; +import { SingleFeatureGuardParamFn } from './single-feature-authorization.guard'; /** * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature * This guard utilizes a resolver to retrieve the relevant object to check authorizations for */ -export abstract class DsoPageSingleFeatureGuard extends DsoPageSomeFeatureGuard { - /** - * The features to check authorization for - */ - getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.getFeatureID(route, state).pipe( - map((featureID) => [featureID]), - ); - } - - /** - * The type of feature to check authorization for - * Override this method to define a feature - */ - abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable; -} +export const dsoPageSingleFeatureGuard = ( + getResolveFn: () => ResolveFn>, + getFeatureID: SingleFeatureGuardParamFn, +): CanActivateFn => dsoPageSomeFeatureGuard( + getResolveFn, + (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable => getFeatureID(route, state).pipe( + map((featureID: FeatureID) => [featureID]), + )); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts index 071b1b0731f..08f1c96b299 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts @@ -1,77 +1,82 @@ -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { Observable, of as observableOf } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { + ResolveFn, + Router, + UrlTree, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { AuthService } from '../../../auth/auth.service'; import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { AuthService } from '../../../auth/auth.service'; -import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; +import { + defaultDSOGetObjectUrl, + dsoPageSomeFeatureGuard, + getRouteWithDSOId, +} from './dso-page-some-feature.guard'; -/** - * Test implementation of abstract class DsoPageSomeFeatureGuard - */ -class DsoPageSomeFeatureGuardImpl extends DsoPageSomeFeatureGuard { - constructor(protected resolver: Resolve>, - protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService, - protected featureIDs: FeatureID[]) { - super(resolver, authorizationService, router, authService); - } - - getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.featureIDs); - } -} -describe('DsoPageSomeFeatureGuard', () => { - let guard: DsoPageSomeFeatureGuard; +describe('dsoPageSomeFeatureGuard and its functions', () => { let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; - let resolver: Resolve>; + let resolver: ResolveFn>; let object: DSpaceObject; let route; let parentRoute; + let featureIds: FeatureID[]; + function init() { object = { - self: 'test-selflink' + self: 'test-selflink', } as DSpaceObject; - + featureIds = [FeatureID.LoginOnBehalfOf, FeatureID.CanDelete]; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} - }); - resolver = jasmine.createSpyObj('resolver', { - resolve: createSuccessfulRemoteDataObject$(object) + parseUrl: {}, }); + resolver = () => createSuccessfulRemoteDataObject$(object); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); parentRoute = { params: { - id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0' - } + id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0', + }, }; route = { params: { }, - parent: parentRoute + parent: parentRoute, }; - guard = new DsoPageSomeFeatureGuardImpl(resolver, authorizationService, router, authService, []); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + ], + }); + } beforeEach(() => { init(); }); - describe('getObjectUrl', () => { + + describe('defaultDSOGetObjectUrl', () => { it('should return the resolved object\'s selflink', (done) => { - guard.getObjectUrl(route, undefined).subscribe((selflink) => { + defaultDSOGetObjectUrl(resolver)(route, undefined).subscribe((selflink) => { expect(selflink).toEqual(object.self); done(); }); @@ -80,8 +85,26 @@ describe('DsoPageSomeFeatureGuard', () => { describe('getRouteWithDSOId', () => { it('should return the route that has the UUID of the DSO', () => { - const foundRoute = (guard as any).getRouteWithDSOId(route); + const foundRoute = getRouteWithDSOId(route); expect(foundRoute).toBe(parentRoute); }); }); + + + describe('dsoPageSomeFeatureGuard', () => { + it('should call authorizationService.isAuthenticated with the appropriate arguments', (done) => { + const result$ = TestBed.runInInjectionContext(() => { + return dsoPageSomeFeatureGuard( + () => resolver, () => observableOf(featureIds), + )(route, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe(() => { + featureIds.forEach((featureId: FeatureID) => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, object.self, undefined); + }); + done(); + }); + }); + }); }); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts index 86837093459..7469f113b49 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts @@ -1,46 +1,60 @@ -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { AuthorizationDataService } from '../authorization-data.service'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; import { map } from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, +} from '../../../../shared/empty.util'; import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { AuthService } from '../../../auth/auth.service'; -import { hasNoValue, hasValue } from '../../../../shared/empty.util'; -import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; +import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; +import { RemoteData } from '../../remote-data'; +import { FeatureID } from '../feature-id'; +import { + someFeatureAuthorizationGuard, + SomeFeatureGuardParamFn, + StringGuardParamFn, +} from './some-feature-authorization.guard'; + +export declare type DSOGetObjectURlFn = (resolve: ResolveFn>) => StringGuardParamFn; + /** - * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for any specific feature in a list - * This guard utilizes a resolver to retrieve the relevant object to check authorizations for + * Method to resolve resolve (parent) route that contains the UUID of the DSO + * @param route The current route */ -export abstract class DsoPageSomeFeatureGuard extends SomeFeatureAuthorizationGuard { - constructor(protected resolver: Resolve>, - protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService) { - super(authorizationService, router, authService); +export const getRouteWithDSOId = (route: ActivatedRouteSnapshot): ActivatedRouteSnapshot => { + let routeWithDSOId = route; + while (hasNoValue(routeWithDSOId.params.id) && hasValue(routeWithDSOId.parent)) { + routeWithDSOId = routeWithDSOId.parent; } + return routeWithDSOId; +}; - /** - * Check authorization rights for the object resolved using the provided resolver - */ - getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const routeWithObjectID = this.getRouteWithDSOId(route); - return (this.resolver.resolve(routeWithObjectID, state) as Observable>).pipe( + + +export const defaultDSOGetObjectUrl: DSOGetObjectURlFn = (resolve: ResolveFn>): StringGuardParamFn => { + return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable => { + const routeWithObjectID = getRouteWithDSOId(route); + return (resolve(routeWithObjectID, state) as Observable>).pipe( getAllSucceededRemoteDataPayload(), - map((dso) => dso.self) + map((dso) => dso.self), ); - } + }; +}; - /** - * Method to resolve resolve (parent) route that contains the UUID of the DSO - * @param route The current route - */ - protected getRouteWithDSOId(route: ActivatedRouteSnapshot): ActivatedRouteSnapshot { - let routeWithDSOId = route; - while (hasNoValue(routeWithDSOId.params.id) && hasValue(routeWithDSOId.parent)) { - routeWithDSOId = routeWithDSOId.parent; - } - return routeWithDSOId; - } -} +/** + * Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for any specific feature in a list + * This guard utilizes a resolver to retrieve the relevant object to check authorizations for + */ +export const dsoPageSomeFeatureGuard = ( + getResolveFn: () => ResolveFn>, + getFeatureIDs: SomeFeatureGuardParamFn, + getObjectUrl: DSOGetObjectURlFn = defaultDSOGetObjectUrl, + getEPersonUuid?: StringGuardParamFn, +): CanActivateFn => someFeatureAuthorizationGuard((route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable => getFeatureIDs(route, state), getObjectUrl(getResolveFn()), getEPersonUuid); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts index 5afd572326f..9641d0aace9 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts @@ -1,27 +1,12 @@ -import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + import { FeatureID } from '../feature-id'; +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group * management rights */ -@Injectable({ - providedIn: 'root' -}) -export class GroupAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } - - /** - * Check group management rights - */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.CanManageGroups); - } -} +export const groupAdministratorGuard: CanActivateFn = + singleFeatureAuthorizationGuard(() => observableOf(FeatureID.CanManageGroups)); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts index 635aa3530bd..7c15fa4cdff 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts @@ -1,39 +1,22 @@ -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Router, + UrlTree, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../auth/auth.service'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { Observable, of as observableOf } from 'rxjs'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; - -/** - * Test implementation of abstract class SingleFeatureAuthorizationGuard - * Provide the return values of the overwritten getters as constructor arguments - */ -class SingleFeatureAuthorizationGuardImpl extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService, - protected featureId: FeatureID, - protected objectUrl: string, - protected ePersonUuid: string) { - super(authorizationService, router, authService); - } - - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.featureId); - } - - getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.objectUrl); - } +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; - getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.ePersonUuid); - } -} - -describe('SingleFeatureAuthorizationGuard', () => { - let guard: SingleFeatureAuthorizationGuard; +describe('singleFeatureAuthorizationGuard', () => { let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; @@ -48,25 +31,44 @@ describe('SingleFeatureAuthorizationGuard', () => { ePersonUuid = 'fake-eperson-uuid'; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), + }); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + ], }); - guard = new SingleFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid); } - beforeEach(() => { + beforeEach(waitForAsync(() => { init(); - }); + })); describe('canActivate', () => { - it('should call authorizationService.isAuthenticated with the appropriate arguments', () => { - guard.canActivate(undefined, { url: 'current-url' } as any).subscribe(); - expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, objectUrl, ePersonUuid); + it('should call authorizationService.isAuthenticated with the appropriate arguments', (done: DoneFn) => { + const result$ = TestBed.runInInjectionContext(() => { + return singleFeatureAuthorizationGuard( + () => observableOf(featureId), + () => observableOf(objectUrl), + () => observableOf(ePersonUuid), + )(undefined, { url: 'current-url' } as any); + }) as Observable; + + + result$.subscribe(() => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, objectUrl, ePersonUuid); + done(); + }); }); + }); }); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts index cb71d2f4181..995dcb6f5c4 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts @@ -1,27 +1,35 @@ -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { FeatureID } from '../feature-id'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { map} from 'rxjs/operators'; -import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; +import { map } from 'rxjs/operators'; + +import { FeatureID } from '../feature-id'; +import { + someFeatureAuthorizationGuard, + StringGuardParamFn, +} from './some-feature-authorization.guard'; + +export declare type SingleFeatureGuardParamFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable; /** - * Abstract Guard for preventing unauthorized activating and loading of routes when a user - * doesn't have authorized rights on a specific feature and/or object. - * Override the desired getters in the parent class for checking specific authorization on a feature and/or object. + * Guard for preventing unauthorized activating and loading of routes when a user doesn't have + * authorized rights on a specific feature and/or object. + * + * @param getFeatureID The feature to check authorization for + * @param getObjectUrl The URL of the object to check if the user has authorized rights for, + * Optional, if not provided, the {@link Site}'s URL will be assumed + * @param getEPersonUuid The UUID of the user to check authorization rights for. + * Optional, if not provided, the authenticated user's UUID will be assumed. */ -export abstract class SingleFeatureAuthorizationGuard extends SomeFeatureAuthorizationGuard { - /** - * The features to check authorization for - */ - getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.getFeatureID(route, state).pipe( - map((featureID) => [featureID]), - ); - } - /** - * The type of feature to check authorization for - * Override this method to define a feature - */ - abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable; -} +export const singleFeatureAuthorizationGuard = ( + getFeatureID: SingleFeatureGuardParamFn, + getObjectUrl?: StringGuardParamFn, + getEPersonUuid?: StringGuardParamFn, +): CanActivateFn => someFeatureAuthorizationGuard( + (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable => getFeatureID(route, state).pipe( + map((featureID: FeatureID) => [featureID]), + ), getObjectUrl, getEPersonUuid); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts index cc6f50c1613..4caa1f806d9 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts @@ -1,27 +1,12 @@ -import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + import { FeatureID } from '../feature-id'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; -import { AuthService } from '../../../auth/auth.service'; +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator * rights to the {@link Site} */ -@Injectable({ - providedIn: 'root' -}) -export class SiteAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } - - /** - * Check administrator authorization rights - */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.AdministratorOf); - } -} +export const siteAdministratorGuard: CanActivateFn = + singleFeatureAuthorizationGuard(() => observableOf(FeatureID.AdministratorOf)); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts index bdbb8250e27..ee08532d38f 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts @@ -1,27 +1,12 @@ -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { Injectable } from '@angular/core'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + import { FeatureID } from '../feature-id'; -import { AuthService } from '../../../auth/auth.service'; +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration * rights to the {@link Site} */ -@Injectable({ - providedIn: 'root' -}) -export class SiteRegisterGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } - - /** - * Check registration authorization rights - */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.EPersonRegistration); - } -} +export const siteRegisterGuard: CanActivateFn = + singleFeatureAuthorizationGuard(() => observableOf(FeatureID.EPersonRegistration)); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts index 90153d2d148..79e023bdd0f 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts @@ -1,39 +1,22 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Router, + UrlTree, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../auth/auth.service'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { Observable, of as observableOf } from 'rxjs'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; -import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; - -/** - * Test implementation of abstract class SomeFeatureAuthorizationGuard - * Provide the return values of the overwritten getters as constructor arguments - */ -class SomeFeatureAuthorizationGuardImpl extends SomeFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService, - protected featureIds: FeatureID[], - protected objectUrl: string, - protected ePersonUuid: string) { - super(authorizationService, router, authService); - } - - getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.featureIds); - } - - getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.objectUrl); - } - - getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(this.ePersonUuid); - } -} +import { someFeatureAuthorizationGuard } from './some-feature-authorization.guard'; describe('SomeFeatureAuthorizationGuard', () => { - let guard: SomeFeatureAuthorizationGuard; let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; @@ -52,20 +35,29 @@ describe('SomeFeatureAuthorizationGuard', () => { authorizationService = Object.assign({ isAuthorized(featureId?: FeatureID): Observable { return observableOf(authorizedFeatureIds.indexOf(featureId) > -1); - } + }, }); + router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); + authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), + }); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + ], }); - guard = new SomeFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureIds, objectUrl, ePersonUuid); } - beforeEach(() => { + beforeEach(waitForAsync(() => { init(); - }); + })); describe('canActivate', () => { describe('when the user isn\'t authorized', () => { @@ -74,7 +66,16 @@ describe('SomeFeatureAuthorizationGuard', () => { }); it('should not return true', (done) => { - guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => { + + const result$ = TestBed.runInInjectionContext(() => { + return someFeatureAuthorizationGuard( + () => observableOf(featureIds), + () => observableOf(objectUrl), + () => observableOf(ePersonUuid), + )(undefined, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe((result) => { expect(result).not.toEqual(true); done(); }); @@ -87,7 +88,16 @@ describe('SomeFeatureAuthorizationGuard', () => { }); it('should return true', (done) => { - guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => { + + const result$ = TestBed.runInInjectionContext(() => { + return someFeatureAuthorizationGuard( + () => observableOf(featureIds), + () => observableOf(objectUrl), + () => observableOf(ePersonUuid), + )(undefined, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe((result) => { expect(result).toEqual(true); done(); }); @@ -100,7 +110,16 @@ describe('SomeFeatureAuthorizationGuard', () => { }); it('should return true', (done) => { - guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => { + + const result$ = TestBed.runInInjectionContext(() => { + return someFeatureAuthorizationGuard( + () => observableOf(featureIds), + () => observableOf(objectUrl), + () => observableOf(ePersonUuid), + )(undefined, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe((result) => { expect(result).toEqual(true); done(); }); 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 b909640ea64..53e5e582eb8 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts @@ -1,54 +1,56 @@ -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { FeatureID } from '../feature-id'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; + import { AuthService } from '../../../auth/auth.service'; import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/authorized.operators'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; + +export declare type SomeFeatureGuardParamFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable; +export declare type StringGuardParamFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable; +export const defaultStringGuardParamFn = () => observableOf(undefined); /** - * Abstract Guard for preventing unauthorized activating and loading of routes when a user - * doesn't have authorized rights on any of the specified features and/or object. - * Override the desired getters in the parent class for checking specific authorization on a list of features and/or object. - */ -export abstract class SomeFeatureAuthorizationGuard implements CanActivate { - constructor(protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService) { - } + * Guard for preventing unauthorized activating and loading of routes when a user doesn't have + * authorized rights on any of the specified features and/or object. - /** - * True when user has authorization rights for the feature and object provided - * 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( - switchMap(([featureIDs, objectUrl, ePersonUuid]) => - observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))) + * @param getFeatureIDs The features to check authorization for + * @param getObjectUrl The URL of the object to check if the user has authorized rights for, + * Optional, if not provided, the {@link Site}'s URL will be assumed + * @param getEPersonUuid The UUID of the user to check authorization rights for. + * Optional, if not provided, the authenticated user's UUID will be assumed. + */ +export const someFeatureAuthorizationGuard = ( + getFeatureIDs: SomeFeatureGuardParamFn, + getObjectUrl: StringGuardParamFn = defaultStringGuardParamFn, + getEPersonUuid: StringGuardParamFn = defaultStringGuardParamFn, +): CanActivateFn => { + return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable => { + const authorizationService = inject(AuthorizationDataService); + const router = inject(Router); + const authService = inject(AuthService); + return observableCombineLatest([ + getFeatureIDs(route, state), + getObjectUrl(route, state), + getEPersonUuid(route, state), + ]).pipe( + switchMap(([featureIDs, objectUrl, ePersonUuid]: [FeatureID[], string, string]) => + observableCombineLatest(featureIDs.map((featureID) => authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))), ), - returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url) + returnForbiddenUrlTreeOrLoginOnAllFalse(router, authService, state.url), ); - } - - /** - * The features to check authorization for - * Override this method to define a list of features - */ - abstract getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable; - - /** - * The URL of the object to check if the user has authorized rights for - * Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used - */ - getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(undefined); - } + }; +}; - /** - * The UUID of the user to check authorization rights for - * Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used. - */ - getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(undefined); - } -} diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts index 680495686eb..21cafeaba3f 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts @@ -1,27 +1,12 @@ -import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { CanActivateFn } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + import { FeatureID } from '../feature-id'; +import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group * management rights */ -@Injectable({ - providedIn: 'root' -}) -export class StatisticsAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } - - /** - * Check group management rights - */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.CanViewUsageStatistics); - } -} +export const statisticsAdministratorGuard: CanActivateFn = + singleFeatureAuthorizationGuard(() => observableOf(FeatureID.CanViewUsageStatistics)); diff --git a/src/app/core/data/feature-authorization/feature-data.service.ts b/src/app/core/data/feature-authorization/feature-data.service.ts index eda87911539..191d8b002ca 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -1,18 +1,16 @@ import { Injectable } from '@angular/core'; -import { FEATURE } from '../../shared/feature.resource-type'; -import { Feature } from '../../shared/feature.model'; -import { RequestService } from '../request.service'; + import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Feature } from '../../shared/feature.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { BaseDataService } from '../base/base-data.service'; -import { dataService } from '../base/data-service.decorator'; +import { RequestService } from '../request.service'; /** * A service to retrieve {@link Feature}s from the REST API */ -@Injectable() -@dataService(FEATURE) +@Injectable({ providedIn: 'root' }) export class FeatureDataService extends BaseDataService { protected linkPath = 'features'; diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 8fef45a9532..3d5803b018b 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -34,4 +34,7 @@ export enum FeatureID { CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', CanSubscribe = 'canSubscribeDso', + CoarNotifyEnabled = 'coarNotifyEnabled', + CanSeeQA = 'canSeeQA', + EPersonForgotPassword = 'epersonForgotPassword' } diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts index ac0e96a2e6b..6966e6a6311 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts @@ -1,10 +1,10 @@ -import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { GetRequest } from './request.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; describe('FilteredDiscoveryPageResponseParsingService', () => { let service: FilteredDiscoveryPageResponseParsingService; @@ -17,15 +17,15 @@ describe('FilteredDiscoveryPageResponseParsingService', () => { const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), { getResponseParser(): GenericConstructor { return FilteredDiscoveryPageResponseParsingService; - } + }, }); const mockResponse = { payload: { - 'discovery-query': 'query' + 'discovery-query': 'query', }, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => { diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts index da7a21c488f..e07f46e9160 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -1,16 +1,20 @@ import { Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; + +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + FilteredDiscoveryQueryResponse, + RestResponse, +} from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a discovery query (string) * wrapped in a FilteredDiscoveryQueryResponse */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class FilteredDiscoveryPageResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { objectFactory = {}; toCache = false; diff --git a/src/app/core/data/find-list-options.model.ts b/src/app/core/data/find-list-options.model.ts index dc567d4b531..78fe26fcab9 100644 --- a/src/app/core/data/find-list-options.model.ts +++ b/src/app/core/data/find-list-options.model.ts @@ -1,15 +1,15 @@ -import { SortOptions } from '../cache/models/sort-options.model'; import { RequestParam } from '../cache/models/request-param.model'; +import { SortOptions } from '../cache/models/sort-options.model'; /** * The options for a find list request */ export class FindListOptions { - scopeID?: string; - elementsPerPage?: number; - currentPage?: number; - sort?: SortOptions; - searchParams?: RequestParam[]; - startsWith?: string; - fetchThumbnail?: boolean; + scopeID?: string; + elementsPerPage?: number; + currentPage?: number; + sort?: SortOptions; + searchParams?: RequestParam[]; + startsWith?: string; + fetchThumbnail?: boolean; } diff --git a/src/app/core/data/href-only-data.service.spec.ts b/src/app/core/data/href-only-data.service.spec.ts index bf7d2890ea2..cba85d5de65 100644 --- a/src/app/core/data/href-only-data.service.spec.ts +++ b/src/app/core/data/href-only-data.service.spec.ts @@ -1,8 +1,11 @@ -import { HrefOnlyDataService } from './href-only-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { FindListOptions } from './find-list-options.model'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { BaseDataService } from './base/base-data.service'; +import { FindListOptions } from './find-list-options.model'; +import { HrefOnlyDataService } from './href-only-data.service'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; @@ -23,60 +26,60 @@ describe(`HrefOnlyDataService`, () => { expect((service as any).dataService).toBeInstanceOf(BaseDataService); }); - describe(`findByHref`, () => { - beforeEach(() => { - spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); - }); + describe(`findByHref`, () => { + beforeEach(() => { + spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + }); - it(`should forward to findByHref on the internal DataService`, () => { - service.findByHref(href, false, false, ...followLinks); - expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); - }); + it(`should forward to findByHref on the internal DataService`, () => { + service.findByHref(href, false, false, ...followLinks); + expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); + }); - describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), true, jasmine.anything()); - }); + describe(`when useCachedVersionIfAvailable is omitted`, () => { + it(`should call findByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), true, jasmine.anything()); }); + }); - describe(`when reRequestOnStale is omitted`, () => { - it(`should call findByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true); - }); + describe(`when reRequestOnStale is omitted`, () => { + it(`should call findByHref on the internal DataService with reRequestOnStale = true`, () => { + service.findByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true); }); }); + }); - describe(`findListByHref`, () => { - beforeEach(() => { - spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); - }); + describe(`findListByHref`, () => { + beforeEach(() => { + spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + }); - it(`should delegate to findListByHref on the internal DataService`, () => { - service.findListByHref(href, findListOptions, false, false, ...followLinks); - expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); - }); + it(`should delegate to findListByHref on the internal DataService`, () => { + service.findListByHref(href, findListOptions, false, false, ...followLinks); + expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); + }); - describe(`when findListOptions is omitted`, () => { - it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); - }); + describe(`when findListOptions is omitted`, () => { + it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); }); + }); - describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); - }); + describe(`when useCachedVersionIfAvailable is omitted`, () => { + it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); }); + }); - describe(`when reRequestOnStale is omitted`, () => { - it(`should call findListByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), jasmine.anything(), true); - }); + describe(`when reRequestOnStale is omitted`, () => { + it(`should call 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 0a765de101b..dd40be8f7df 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,24 +1,21 @@ -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 { Injectable } from '@angular/core'; -import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; -import { PaginatedList } from './paginated-list.model'; -import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; -import { LICENSE } from '../shared/license.resource-type'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CacheableObject } from '../cache/cacheable-object.model'; -import { FindListOptions } from './find-list-options.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { BaseDataService } from './base/base-data.service'; import { HALDataService } from './base/hal-data-service.interface'; -import { dataService } from './base/data-service.decorator'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** - * A DataService with only findByHref methods. Its purpose is to be used for resources that don't - * need to be retrieved by ID, or have any way to update them, but require a DataService in order + * A UpdateDataServiceImpl with only findByHref methods. Its purpose is to be used for resources that don't + * need to be retrieved by ID, or have any way to update them, but require a UpdateDataServiceImpl in order * for their links to be resolved by the LinkService. * * an @dataService annotation can be added for any number of these resource types @@ -32,12 +29,7 @@ import { dataService } from './base/data-service.decorator'; * ``` * This means we cannot extend from {@link BaseDataService} directly because the method signatures would not match. */ -@Injectable({ - providedIn: 'root', -}) -@dataService(VOCABULARY_ENTRY) -@dataService(ITEM_TYPE) -@dataService(LICENSE) +@Injectable({ providedIn: 'root' }) export class HrefOnlyDataService implements HALDataService { /** * Works with a {@link BaseDataService} internally, but only exposes two of its methods diff --git a/src/app/core/data/identifier-data.service.ts b/src/app/core/data/identifier-data.service.ts index 03422dadfb0..502b1fe7107 100644 --- a/src/app/core/data/identifier-data.service.ts +++ b/src/app/core/data/identifier-data.service.ts @@ -1,27 +1,33 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { + HttpClient, + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from './base/data-service.decorator'; +import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core-state.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { sendRequest } from '../shared/request.operators'; import { BaseDataService } from './base/base-data.service'; -import { RequestService } from './request.service'; +import { ConfigurationDataService } from './configuration-data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { CoreState } from '../core-state.model'; -import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; -import { Item } from '../shared/item.model'; -import { IDENTIFIERS } from '../../shared/object-list/identifier-data/identifier-data.resource-type'; -import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { map, switchMap } from 'rxjs/operators'; -import {ConfigurationProperty} from '../shared/configuration-property.model'; -import {ConfigurationDataService} from './configuration-data.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from './request.models'; -import { sendRequest } from '../shared/request.operators'; +import { RequestService } from './request.service'; import { RestRequest } from './rest-request.model'; /** @@ -29,8 +35,7 @@ import { RestRequest } from './rest-request.model'; * from the /identifiers endpoint, as well as the backend configuration that controls whether a 'Register DOI' * button appears for admins in the item status page */ -@Injectable() -@dataService(IDENTIFIERS) +@Injectable({ providedIn: 'root' }) export class IdentifierDataService extends BaseDataService { constructor( @@ -61,7 +66,7 @@ export class IdentifierDataService extends BaseDataService { public getIdentifierRegistrationConfiguration(): Observable { return this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe( getFirstCompletedRemoteData(), - map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), ); } @@ -79,7 +84,7 @@ export class IdentifierDataService extends BaseDataService { return new PostRequest(requestId, endpointURL, item._links.self.href, options); }), sendRequest(this.requestService), - switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>), ); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 2c20ed0fb69..dd60d940702 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,25 +1,32 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { BrowseService } from '../browse/browse.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; +import { CoreState } from '../core-state.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { FindListOptions } from './find-list-options.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; +import { + 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; @@ -46,7 +53,7 @@ describe('ItemDataService', () => { const objectCache = {} as ObjectCacheService; const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { - findByHref: {} + findByHref: {}, }); const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; @@ -54,8 +61,8 @@ describe('ItemDataService', () => { scopeID: scopeID, sort: { field: '', - direction: undefined - } + direction: undefined, + }, }); const browsesEndpoint = 'https://rest.api/discover/browses'; @@ -73,7 +80,7 @@ describe('ItemDataService', () => { cold('--a-', { a: itemBrowseEndpoint }) : cold('--#-', undefined, browseError); return jasmine.createSpyObj('bs', { - getBrowseURLFor: obs + getBrowseURLFor: obs, }); } @@ -158,7 +165,7 @@ describe('ItemDataService', () => { const externalSourceEntry = Object.assign(new ExternalSourceEntry(), { display: 'John, Doe', value: 'John, Doe', - _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } } + _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } }, }); beforeEach(() => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c3fa84dd6c8..e1f789b5da7 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -8,44 +8,71 @@ /* eslint-disable max-classes-per-file */ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { + distinctUntilChanged, + filter, + find, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Bundle } from '../shared/bundle.model'; import { Collection } from '../shared/collection.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { ITEM } from '../shared/item.resource-type'; +import { MetadataMap } from '../shared/metadata.models'; +import { NoContent } from '../shared/NoContent.model'; +import { sendRequest } from '../shared/request.operators'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { BundleDataService } from './bundle-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; +import { ResponseParsingService } from './parsing.service'; import { RemoteData } from './remote-data'; -import { DeleteRequest, GetRequest, PostRequest, PutRequest } 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'; -import { MetadataMap } from '../shared/metadata.models'; -import { BundleDataService } from './bundle-data.service'; -import { Operation } from 'fast-json-patch'; -import { NoContent } from '../shared/NoContent.model'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { ResponseParsingService } from './parsing.service'; -import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; -import { sendRequest } from '../shared/request.operators'; import { RestRequest } from './rest-request.model'; -import { 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'; +import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; /** * An abstract service for CRUD operations on Items @@ -140,7 +167,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService return new PostRequest(this.requestService.generateRequestId(), endpointURL, collectionHref, options); }), sendRequest(this.requestService), - switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)) + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), ); } @@ -152,7 +179,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService public setWithDrawn(item: Item, withdrawn: boolean): Observable> { const patchOperation = { - op: 'replace', path: '/withdrawn', value: withdrawn + op: 'replace', path: '/withdrawn', value: withdrawn, } as Operation; this.requestService.removeByHrefSubstring('/discover'); @@ -166,7 +193,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public setDiscoverable(item: Item, discoverable: boolean): Observable> { const patchOperation = { - op: 'replace', path: '/discoverable', value: discoverable + op: 'replace', path: '/discoverable', value: discoverable, } as Operation; this.requestService.removeByHrefSubstring('/discover'); @@ -180,7 +207,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBundlesEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)), ); } @@ -191,10 +218,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable>> { const hrefObs = this.getBundlesEndpoint(itemId).pipe( - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href), ); hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const request = new GetRequest(this.requestService.generateRequestId(), href); this.requestService.send(request); @@ -215,11 +242,11 @@ export abstract class BaseItemDataService extends IdentifiableDataService const bundleJson = { name: bundleName, - metadata: metadata ? metadata : {} + metadata: metadata ? metadata : {}, }; hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -238,7 +265,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getIdentifiersEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`)), ); } @@ -249,7 +276,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService public getMoveItemEndpoint(itemId: string, inheritPolicies: boolean): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`) + map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`), ); } @@ -275,10 +302,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService // TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code getResponseParser(): GenericConstructor { return StatusCodeOnlyResponseParsingService; - } + }, }); return request; - }) + }), ).subscribe((request) => { this.requestService.send(request); }); @@ -305,7 +332,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService map((href: string) => { const request = new PostRequest(requestId, href, externalSourceEntry._links.self.href, options); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); @@ -317,7 +344,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBitstreamsEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)), ); } @@ -403,8 +430,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService /** * A service for CRUD operations on Items */ -@Injectable() -@dataService(ITEM) +@Injectable({ providedIn: 'root' }) export class ItemDataService extends BaseItemDataService { constructor( protected requestService: RequestService, diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index a5d18725109..68577ae6e26 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -1,12 +1,13 @@ -import { ItemRequestDataService } from './item-request-data.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { of as observableOf } from 'rxjs'; + +import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; +import { ItemRequestDataService } from './item-request-data.service'; import { PostRequest } from './request.models'; -import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; describe('ItemRequestDataService', () => { diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index ff6025f7ac8..5c85ed1471d 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -1,20 +1,32 @@ +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + find, + map, +} from 'rxjs/operators'; + +import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { RemoteData } from './remote-data'; -import { PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { ItemRequest } from '../shared/item-request.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HttpHeaders } from '@angular/common/http'; -import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; import { IdentifiableDataService } from './base/identifiable-data.service'; +import { RemoteData } from './remote-data'; +import { + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint @@ -60,11 +72,11 @@ export class ItemRequestDataService extends IdentifiableDataService map((href: string) => { const request = new PostRequest(requestId, href, itemRequest); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ); } diff --git a/src/app/core/data/item-template-data.service.spec.ts b/src/app/core/data/item-template-data.service.spec.ts index 16cf0dbd99d..27db819861c 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -1,22 +1,26 @@ -import { ItemTemplateDataService } from './item-template-data.service'; -import { RestResponse } from '../cache/response.models'; -import { RequestService } from './request.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { CollectionDataService } from './collection-data.service'; -import { RestRequestMethod } from './rest-request-method'; -import { Item } from '../shared/item.model'; -import { RestRequest } from './rest-request.model'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { CollectionDataService } from './collection-data.service'; +import { ItemTemplateDataService } from './item-template-data.service'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; +import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; import createSpyObj = jasmine.createSpyObj; describe('ItemTemplateDataService', () => { @@ -46,7 +50,7 @@ describe('ItemTemplateDataService', () => { }, commit(method?: RestRequestMethod) { // Do nothing - } + }, } as RequestService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; @@ -62,18 +66,18 @@ describe('ItemTemplateDataService', () => { const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: itemEndpoint }); - } + }, } as HALEndpointService; const notificationsService = {} as NotificationsService; const comparator = { diff(first, second) { return [{}]; - } + }, } as any; const collectionService = { getIDHrefObs(id): Observable { return observableOf(collectionEndpoint); - } + }, } as CollectionDataService; function initTestService() { diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index 634c966dbaa..f89a297fad9 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,22 +1,23 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { BaseItemDataService } from './item-data.service'; -import { Item } from '../shared/item.model'; -import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RequestService } from './request.service'; +import { switchMap } from 'rxjs/operators'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { BrowseService } from '../browse/browse.service'; -import { CollectionDataService } from './collection-data.service'; -import { switchMap } from 'rxjs/operators'; -import { BundleDataService } from './bundle-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; +import { Item } from '../shared/item.model'; import { CreateDataImpl } from './base/create-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { BundleDataService } from './bundle-data.service'; +import { CollectionDataService } from './collection-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { BaseItemDataService } from './item-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Data service for interacting with Item templates via their Collection @@ -63,7 +64,7 @@ class CollectionItemTemplateDataService extends IdentifiableDataService { /** * A service responsible for fetching/sending data from/to the REST API on a collection's itemtemplates endpoint */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ItemTemplateDataService extends BaseItemDataService { private byCollection: CollectionItemTemplateDataService; diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index 58598b9870d..93b4ed66968 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -1,18 +1,22 @@ -import { LookupRelationService } from './lookup-relation.service'; -import { ExternalSourceDataService } from './external-source-data.service'; -import { SearchService } from '../shared/search/search.service'; +import { of as observableOf } from 'rxjs'; +import { + skip, + take, +} from 'rxjs/operators'; + +import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { buildPaginatedList } from './paginated-list.model'; -import { PageInfo } from '../shared/page-info.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; import { SearchResult } from '../../shared/search/models/search-result.model'; -import { Item } from '../shared/item.model'; -import { skip, take } from 'rxjs/operators'; +import { createPaginatedList } from '../../shared/testing/utils.test'; import { ExternalSource } from '../shared/external-source.model'; +import { Item } from '../shared/item.model'; +import { PageInfo } from '../shared/page-info.model'; +import { SearchService } from '../shared/search/search.service'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { LookupRelationService } from './lookup-relation.service'; +import { buildPaginatedList } from './paginated-list.model'; import { RequestService } from './request.service'; -import { of as observableOf } from 'rxjs'; describe('LookupRelationService', () => { let service: LookupRelationService; @@ -24,20 +28,20 @@ describe('LookupRelationService', () => { const optionsWithQuery = new PaginatedSearchOptions({ query: 'test-query' }); const relationship = Object.assign(new RelationshipOptions(), { filter: 'test-filter', - configuration: 'test-configuration' + configuration: 'test-configuration', }); const localResults = [ Object.assign(new SearchResult(), { indexableObject: Object.assign(new Item(), { uuid: 'test-item-uuid', - handle: 'test-item-handle' - }) - }) + handle: 'test-item-handle', + }), + }), ]; const externalSource = Object.assign(new ExternalSource(), { id: 'orcidV2', name: 'orcidV2', - hierarchical: false + hierarchical: false, }); const searchServiceEndpoint = 'http://test-rest.com/server/api/core/search'; @@ -47,12 +51,12 @@ describe('LookupRelationService', () => { elementsPerPage: 1, totalElements: totalExternal, totalPages: totalExternal, - currentPage: 1 - }), [{}])) + currentPage: 1, + }), [{}])), }); searchService = jasmine.createSpyObj('searchService', { search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)), - getEndpoint: observableOf(searchServiceEndpoint) + getEndpoint: observableOf(searchServiceEndpoint), }); requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); service = new LookupRelationService(externalSourceService, searchService, requestService); @@ -77,7 +81,7 @@ describe('LookupRelationService', () => { it('should set the searchConfig to contain a fixedFilter and configuration', () => { expect(service.searchConfig).toEqual(Object.assign(new PaginatedSearchOptions({}), optionsWithQuery, - { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration } + { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration }, )); }); }); diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 7a6bc2358b5..55b68afa652 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -1,25 +1,40 @@ -import { ExternalSourceDataService } from './external-source-data.service'; -import { SearchService } from '../shared/search/search.service'; -import { concat, distinctUntilChanged, map, multicast, startWith, take, takeWhile } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { + Observable, + ReplaySubject, +} from 'rxjs'; +import { + concat, + distinctUntilChanged, + map, + multicast, + startWith, + take, + takeWhile, +} from 'rxjs/operators'; + +import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Observable, ReplaySubject } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list.model'; import { SearchResult } from '../../shared/search/models/search-result.model'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; -import { Item } from '../shared/item.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { Injectable } from '@angular/core'; import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { Item } from '../shared/item.model'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { SearchService } from '../shared/search/search.service'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; /** * A service for retrieving local and external entries information during a relation lookup */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class LookupRelationService { /** * The search config last used for retrieving local results @@ -31,7 +46,7 @@ export class LookupRelationService { */ private singleResultOptions = Object.assign(new PaginationComponentOptions(), { id: 'single-result-options', - pageSize: 1 + pageSize: 1, }); constructor(protected externalSourceService: ExternalSourceDataService, @@ -47,7 +62,7 @@ export class LookupRelationService { */ getLocalResults(relationship: RelationshipOptions, searchOptions: PaginatedSearchOptions, setSearchConfig = true): Observable>>> { const newConfig = Object.assign(new PaginatedSearchOptions({}), searchOptions, - { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration } + { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration }, ); if (setSearchConfig) { this.searchConfig = newConfig; @@ -59,8 +74,8 @@ export class LookupRelationService { () => new ReplaySubject(1), (subject) => subject.pipe( takeWhile((rd: RemoteData>>) => rd.isLoading), - concat(subject.pipe(take(1))) - ) + concat(subject.pipe(take(1))), + ), ) as any , ) as Observable>>>; @@ -76,7 +91,7 @@ export class LookupRelationService { getAllSucceededRemoteData(), getRemoteDataPayload(), map((results: PaginatedList>) => results.totalElements), - startWith(0) + startWith(0), ); } @@ -91,7 +106,7 @@ export class LookupRelationService { getRemoteDataPayload(), map((results: PaginatedList) => results.totalElements), startWith(0), - distinctUntilChanged() + distinctUntilChanged(), ); } diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index 1ce078f5d53..8d65038060d 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -1,20 +1,21 @@ -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { MetadataFieldDataService } from './metadata-field-data.service'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; 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 { RestResponse } from '../cache/response.models'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPutDataImplementation } from './base/put-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { FindListOptions } from './find-list-options.model'; +import { MetadataFieldDataService } from './metadata-field-data.service'; +import { RequestService } from './request.service'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -31,8 +32,8 @@ describe('MetadataFieldDataService', () => { prefix: 'dc', namespace: 'namespace', _links: { - self: { href: 'selflink' } - } + self: { href: 'selflink' }, + }, }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', @@ -73,7 +74,7 @@ describe('MetadataFieldDataService', () => { it('should call searchBy with the correct arguments', () => { metadataFieldService.findBySchema(schema); const expectedOptions = Object.assign(new FindListOptions(), { - searchParams: [new RequestParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)], }); expect(metadataFieldService.searchBy).toHaveBeenCalledWith('bySchema', expectedOptions, true, true); }); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index d05e3533d32..aa79ef517ee 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,33 +1,43 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { RequestService } from './request.service'; +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 { HALEndpointService } from '../shared/hal-endpoint.service'; -import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { 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 { HALEndpointService } from '../shared/hal-endpoint.service'; 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 { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; +import { + PutData, + PutDataImpl, +} from './base/put-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint */ -@Injectable() -@dataService(METADATA_FIELD) +@Injectable({ providedIn: 'root' }) export class MetadataFieldDataService extends IdentifiableDataService implements CreateData, PutData, DeleteData, SearchData { private createData: CreateData; private searchData: SearchData; diff --git a/src/app/core/data/metadata-schema-data.service.spec.ts b/src/app/core/data/metadata-schema-data.service.spec.ts index 1bcf4e1104e..02fbc016e7f 100644 --- a/src/app/core/data/metadata-schema-data.service.spec.ts +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -1,16 +1,20 @@ -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { MetadataSchemaDataService } from './metadata-schema-data.service'; import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { CreateRequest, PutRequest } from './request.models'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { RestResponse } from '../cache/response.models'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { MetadataSchemaDataService } from './metadata-schema-data.service'; +import { + CreateRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; describe('MetadataSchemaDataService', () => { let metadataSchemaService: MetadataSchemaDataService; @@ -61,8 +65,8 @@ describe('MetadataSchemaDataService', () => { prefix: 'dc', namespace: 'namespace', _links: { - self: { href: 'selflink' } - } + self: { href: 'selflink' }, + }, }); }); @@ -78,7 +82,7 @@ describe('MetadataSchemaDataService', () => { describe('called with an existing metadata schema', () => { beforeEach(() => { schema = Object.assign(schema, { - id: 'id-of-existing-schema' + id: 'id-of-existing-schema', }); }); diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 6bd633b8c64..e893cb5404b 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,31 +1,41 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { Observable } from 'rxjs'; -import { hasValue } from '../../shared/empty.util'; -import { tap } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; -import { PutData, PutDataImpl } from './base/put-data'; -import { CreateData, CreateDataImpl } from './base/create-data'; import { NoContent } from '../shared/NoContent.model'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PutData, + PutDataImpl, +} from './base/put-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'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ -@Injectable() -@dataService(METADATA_SCHEMA) +@Injectable({ providedIn: 'root' }) export class MetadataSchemaDataService extends IdentifiableDataService implements FindAllData, DeleteData { private createData: CreateData; private findAllData: FindAllData; diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index e46e319149c..f824be7f56b 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -1,21 +1,25 @@ import { Injectable } from '@angular/core'; + +import { hasValue } from '../../shared/empty.util'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { hasValue } from '../../shared/empty.util'; -import { SearchObjects } from '../../shared/search/models/search-objects.model'; -import { MetadataMap, MetadataValue } from '../shared/metadata.models'; +import { + MetadataMap, + MetadataValue, +} from '../shared/metadata.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse { // fallback for unexpected empty response const emptyPayload = { _embedded: { - objects: [] - } + objects: [], + }, }; const payload = data.payload._embedded.searchResult || emptyPayload; const hitHighlights: MetadataMap[] = payload._embedded.objects @@ -26,7 +30,7 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer for (const key of Object.keys(hhObject)) { const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), - language: null + language: null, }); mdMap[key] = [value]; } @@ -46,7 +50,7 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer .map((object, index) => Object.assign({}, object, { indexableObject: dsoSelfLinks[index], hitHighlights: hitHighlights[index], - _embedded: this.filterEmbeddedObjects(object) + _embedded: this.filterEmbeddedObjects(object), })); payload.objects = objects; const deserialized: any = new DSpaceSerializer(SearchObjects).deserialize(payload); @@ -65,8 +69,8 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer .reduce((obj, key) => { obj[key] = object._embedded.indexableObject._embedded[key]; return obj; - }, {}) - }) + }, {}), + }), }); } else { return object; diff --git a/src/app/core/data/notify-services-status-data.service.spec.ts b/src/app/core/data/notify-services-status-data.service.spec.ts new file mode 100644 index 00000000000..e3368435052 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.spec.ts @@ -0,0 +1,88 @@ +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotifyRequestsStatusDataService } from './notify-services-status-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; +import { RequestEntryState } from './request-entry-state.model'; + +describe('NotifyRequestsStatusDataService test', () => { + let scheduler: TestScheduler; + let service: NotifyRequestsStatusDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const endpointURL = `https://rest.api/rest/api/suggestiontargets`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new NotifyRequestsStatusDataService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }), + buildFromHref: createSuccessfulRemoteDataObject$({ test: 'test' }), + }); + + + service = initTestService(); + }); + + describe('getNotifyRequestsStatus', () => { + it('should get notify status', (done) => { + service.getNotifyRequestsStatus(requestUUID).subscribe((status) => { + expect(halService.getEndpoint).toHaveBeenCalled(); + expect(requestService.generateRequestId).toHaveBeenCalled(); + expect(status).toEqual(createSuccessfulRemoteDataObject({ test: 'test' } as unknown as NotifyRequestsStatus)); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/notify-services-status-data.service.ts b/src/app/core/data/notify-services-status-data.service.ts new file mode 100644 index 00000000000..deeaad967a7 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { + map, + Observable, + take, +} from 'rxjs'; + +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; +import { RequestService } from './request.service'; + +@Injectable({ providedIn: 'root' }) +export class NotifyRequestsStatusDataService extends IdentifiableDataService { + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('notifyrequests', requestService, rdbService, objectCache, halService); + } + + /** + * Retrieves the status of notify requests for a specific item. + * @param itemUuid The UUID of the item. + * @returns An Observable that emits the remote data containing the notify requests status. + */ + getNotifyRequestsStatus(itemUuid: string): Observable> { + const href$ = this.getEndpoint().pipe( + map((url: string) => url + '/' + itemUuid ), + ); + + href$.pipe(take(1)).subscribe((url: string) => { + const request = new GetRequest(this.requestService.generateRequestId(), url); + this.requestService.send(request, true); + }); + + return this.rdbService.buildFromHref(href$); + } +} diff --git a/src/app/core/data/object-updates/field-update.model.ts b/src/app/core/data/object-updates/field-update.model.ts index 47b67824713..71ae3bb68f6 100644 --- a/src/app/core/data/object-updates/field-update.model.ts +++ b/src/app/core/data/object-updates/field-update.model.ts @@ -1,5 +1,5 @@ -import { Identifiable } from './identifiable.model'; import { FieldChangeType } from './field-change-type.model'; +import { Identifiable } from './identifiable.model'; /** * The state of a single field update diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 615dedbaf9d..8a95a7827ce 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,11 +1,12 @@ /* eslint-disable max-classes-per-file */ -import { type } from '../../../shared/ngrx/type'; import { Action } from '@ngrx/store'; + +import { type } from '../../../shared/ngrx/type'; import { INotification } from '../../../shared/notifications/models/notification.model'; -import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; import { FieldChangeType } from './field-change-type.model'; +import { Identifiable } from './identifiable.model'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * The list of ObjectUpdatesAction type definitions @@ -20,7 +21,7 @@ export const ObjectUpdatesActionTypes = { REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'), - REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), }; @@ -49,7 +50,7 @@ export class InitializeFieldsAction implements Action { url: string, fields: Identifiable[], lastModified: Date, - patchOperationService?: GenericConstructor + patchOperationService?: GenericConstructor, ) { this.payload = { url, fields, lastModified, patchOperationService }; } @@ -113,7 +114,7 @@ export class SelectVirtualMetadataAction implements Action { uuid: string, select: boolean, ) { - this.payload = { url, source, uuid, select: select}; + this.payload = { url, source, uuid, select: select }; } } @@ -193,7 +194,7 @@ export class DiscardObjectUpdatesAction implements Action { constructor( url: string, notification: INotification, - discardAll = false + discardAll = false, ) { this.payload = { url, notification, discardAll }; } @@ -215,7 +216,7 @@ export class ReinstateObjectUpdatesAction implements Action { * the unique url of the page for which the changes should be reinstated */ constructor( - url: string + url: string, ) { this.payload = { url }; } @@ -237,7 +238,7 @@ export class RemoveObjectUpdatesAction implements Action { * the unique url of the page for which the changes should be removed */ constructor( - url: string + url: string, ) { this.payload = { url }; } @@ -269,7 +270,7 @@ export class RemoveFieldUpdateAction implements Action { */ constructor( url: string, - uuid: string + uuid: string, ) { this.payload = { url, uuid }; } diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts index ffd20a73006..10f37d78cb1 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -1,24 +1,35 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Observable, Subject } from 'rxjs'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; +import { Action } from '@ngrx/store'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + Subject, +} from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { + INotification, + Notification, +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { ObjectUpdatesEffects } from './object-updates.effects'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction + RemoveObjectUpdatesAction, } from './object-updates.actions'; -import { - INotification, - Notification -} from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; -import { filter } from 'rxjs/operators'; -import { hasValue } from '../../../shared/empty.util'; -import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { ObjectUpdatesEffects } from './object-updates.effects'; describe('ObjectUpdatesEffects', () => { let updatesEffects: ObjectUpdatesEffects; @@ -31,13 +42,7 @@ describe('ObjectUpdatesEffects', () => { providers: [ ObjectUpdatesEffects, provideMockActions(() => actions), - { - provide: NotificationsService, - useValue: { - remove: (notification) => { /* empty */ - } - } - }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], }); })); @@ -59,7 +64,6 @@ describe('ObjectUpdatesEffects', () => { action = new RemoveObjectUpdatesAction(testURL); }); it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { - action = new RemoveObjectUpdatesAction(testURL); actions = hot('--a-', { a: action }); (updatesEffects as any).actionMap$[testURL].subscribe((act) => emittedAction = act); const expected = cold('--b-', { b: undefined }); @@ -81,14 +85,19 @@ describe('ObjectUpdatesEffects', () => { removeAction = new RemoveObjectUpdatesAction(testURL); }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( - filter(((action) => hasValue(action)))) - .subscribe((t) => { - expect(t).toEqual(removeAction); - } - ) - ; + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('a', { a: undefined })); + + expect(emittedAction).toEqual(removeAction); }); }); @@ -98,12 +107,24 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return an action with type NO_ACTION', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { - expect(t).toEqual(new NoOpAction()); - } - ); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new ReinstateObjectUpdatesAction(testURL), + }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + take(2), + ).subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); @@ -113,12 +134,22 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new RemoveFieldUpdateAction(testURL, testUUID), + }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => - expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) - ); + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); }); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 1dfdc95f23e..5ef86dbbec3 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -1,24 +1,43 @@ import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; import { - DiscardObjectUpdatesAction, - ObjectUpdatesAction, - ObjectUpdatesActionTypes, - RemoveAllObjectUpdatesAction, - RemoveObjectUpdatesAction -} from './object-updates.actions'; -import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; -import { of as observableOf, race as observableRace, Subject } from 'rxjs'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { + of as observableOf, + race as observableRace, + Subject, +} from 'rxjs'; +import { + delay, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; import { INotification } from '../../../shared/notifications/models/notification.model'; import { NotificationsActions, NotificationsActionTypes, - RemoveNotificationAction + RemoveNotificationAction, } from '../../../shared/notifications/notifications.actions'; -import { Action } from '@ngrx/store'; -import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + RemoveAllObjectUpdatesAction, + RemoveObjectUpdatesAction, +} from './object-updates.actions'; /** * NGRX effects for ObjectUpdatesActions @@ -52,7 +71,7 @@ export class ObjectUpdatesEffects { /** * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key */ - mapLastActions$ = createEffect(() => this.actions$ + mapLastActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { @@ -63,23 +82,23 @@ export class ObjectUpdatesEffects { } this.actionMap$[url].next(action); } - }) + }), ), { dispatch: false }); /** * Effect that makes sure all last fired NotificationActions are stored in the notification map of this service, with the id as their key */ - mapLastNotificationActions$ = createEffect(() => this.actions$ + mapLastNotificationActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(NotificationsActionTypes)), map((action: RemoveNotificationAction) => { - const id: string = action.payload.id || action.payload || this.allIdentifier; - if (hasNoValue(this.notificationActionMap$[id])) { - this.notificationActionMap$[id] = new Subject(); - } - this.notificationActionMap$[id].next(action); + const id: string = action.payload.id || action.payload || this.allIdentifier; + if (hasNoValue(this.notificationActionMap$[id])) { + this.notificationActionMap$[id] = new Subject(); } - ) + this.notificationActionMap$[id].next(action); + }, + ), ), { dispatch: false }); /** @@ -88,52 +107,52 @@ export class ObjectUpdatesEffects { * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned */ - removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ + removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ .pipe( ofType(ObjectUpdatesActionTypes.DISCARD), switchMap((action: DiscardObjectUpdatesAction) => { - const url: string = action.payload.url; - const notification: INotification = action.payload.notification; - const timeOut = notification.options.timeOut; + const url: string = action.payload.url; + const notification: INotification = action.payload.notification; + const timeOut = notification.options.timeOut; - let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); - if (action.payload.discardAll) { - removeAction = new RemoveAllObjectUpdatesAction(); - } - - return observableRace( - // Either wait for the delay and perform a remove action - observableOf(removeAction).pipe(delay(timeOut)), - // Or wait for a a user action - this.actionMap$[url].pipe( - take(1), - tap(() => { - this.notificationsService.remove(notification); - }), - map((updateAction: ObjectUpdatesAction) => { - if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { - // If someone reinstated, do nothing, just let the reinstating happen - return new NoOpAction(); - } - // If someone performed another action, assume the user does not want to reinstate and remove all changes - return removeAction; - }) - ), - this.notificationActionMap$[notification.id].pipe( - filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), - map(() => { - return removeAction; - }) - ), - this.notificationActionMap$[this.allIdentifier].pipe( - filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), - map(() => { - return removeAction; - }) - ) - ); + let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); + if (action.payload.discardAll) { + removeAction = new RemoveAllObjectUpdatesAction(); } - ) + + return observableRace( + // Either wait for the delay and perform a remove action + observableOf(removeAction).pipe(delay(timeOut)), + // Or wait for a a user action + this.actionMap$[url].pipe( + take(1), + tap(() => { + this.notificationsService.remove(notification); + }), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return new NoOpAction(); + } + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return removeAction; + }), + ), + this.notificationActionMap$[notification.id].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), + map(() => { + return removeAction; + }), + ), + this.notificationActionMap$[this.allIdentifier].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), + map(() => { + return removeAction; + }), + ), + ); + }, + ), )); constructor(private actions$: Actions, diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index 08944a073f7..1f2a15769b4 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -1,5 +1,8 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; + +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -10,11 +13,13 @@ import { RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, - SetValidFieldUpdateAction + SetValidFieldUpdateAction, } from './object-updates.actions'; -import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { FieldChangeType } from './field-change-type.model'; +import { + OBJECT_UPDATES_TRASH_PATH, + objectUpdatesReducer, + ObjectUpdatesState, +} from './object-updates.reducer'; class NullAction extends RemoveFieldUpdateAction { type = null; @@ -29,26 +34,26 @@ const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', key: 'dc.contributor.author', language: null, - value: 'Smith, John' + value: 'Smith, John', }; const identifiable1update = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', key: 'dc.contributor.author', language: null, - value: 'Smith, James' + value: 'Smith, James', }; const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', key: 'dc.title', language: null, - value: 'New title' + value: 'New title', }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', key: 'dc.description.abstract', language: null, - value: 'Unchanged value' + value: 'Unchanged value', }; const relationship: Relationship = Object.assign(new Relationship(), { uuid: 'test relationship uuid' }); @@ -56,65 +61,62 @@ const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; const url = 'test-object.url/edit'; describe('objectUpdatesReducer', () => { - const testState = { + const testState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: false + isValid: false, }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, - } + }, }; - const discardedTestState = { + const discardedTestState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, }, [url + OBJECT_UPDATES_TRASH_PATH]: { @@ -122,35 +124,32 @@ describe('objectUpdatesReducer', () => { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: false + isValid: false, }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, - } + }, }; deepFreeze(testState); @@ -173,48 +172,80 @@ describe('objectUpdatesReducer', () => { const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { const action = new SetEditableFieldUpdateAction(url, uuid, false); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_FIELD action without affecting the previous state', () => { const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the DISCARD action without affecting the previous state', () => { const action = new DiscardObjectUpdatesAction(url, null); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REINSTATE action without affecting the previous state', () => { const action = new ReinstateObjectUpdatesAction(url); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE_FIELD action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { @@ -226,19 +257,19 @@ describe('objectUpdatesReducer', () => { [identifiable1.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, }, fieldUpdates: {}, virtualMetadataSources: {}, lastModified: modDate, - patchOperationService: undefined - } + patchOperationService: undefined, + }, }; const newState = objectUpdatesReducer(testState, action); expect(newState).toEqual(expectedState); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 14bacc52db4..cadae9ae838 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,3 +1,14 @@ +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { Item } from '../../shared/item.model'; +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; +import { FieldChangeType } from './field-change-type.model'; +import { FieldUpdates } from './field-updates.model'; +import { Identifiable } from './identifiable.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -11,15 +22,7 @@ import { SetEditableFieldUpdateAction, SetValidFieldUpdateAction, } from './object-updates.actions'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; -import { Item } from '../../shared/item.model'; -import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; -import { FieldUpdates } from './field-updates.model'; -import { FieldChangeType } from './field-change-type.model'; /** * Path where discarded objects are saved @@ -58,6 +61,8 @@ export interface VirtualMetadataSource { export interface RelationshipIdentifiable extends Identifiable { nameVariant?: string; + originalItem: Item; + originalIsLeft: boolean relatedItem: Item; relationship: Relationship; type: RelationshipType; @@ -77,7 +82,7 @@ export interface DeleteRelationship extends RelationshipIdentifiable { */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates; + fieldUpdates?: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; patchOperationService?: GenericConstructor; @@ -164,7 +169,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { { fieldUpdates: {} }, { virtualMetadataSources: {} }, { lastModified: lastModifiedServer }, - { patchOperationService } + { patchOperationService }, ); return Object.assign({}, state, { [url]: newPageState }); } @@ -178,7 +183,7 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { const url: string = action.payload.url; const field: Identifiable = action.payload.field; const changeType: FieldChangeType = action.payload.changeType; - const pageState: ObjectUpdatesEntry = state[url] || {fieldUpdates: {}}; + const pageState: ObjectUpdatesEntry = state[url] || { fieldUpdates: {} }; let states = pageState.fieldStates; if (changeType === FieldChangeType.ADD) { @@ -231,7 +236,7 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) const newPageState = Object.assign( {}, pageState, - {virtualMetadataSources: virtualMetadataSources}, + { virtualMetadataSources: virtualMetadataSources }, ); return Object.assign( @@ -239,7 +244,7 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) state, { [url]: newPageState, - } + }, ); } @@ -279,7 +284,7 @@ function discardObjectUpdatesFor(url: string, state: any) { const discardedPageState = Object.assign({}, pageState, { fieldUpdates: {}, - fieldStates: newFieldStates + fieldStates: newFieldStates, }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -357,7 +362,7 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { } newPageState = Object.assign({}, state[url], { fieldUpdates: newUpdates, - fieldStates: newFieldStates + fieldStates: newFieldStates, }); } return Object.assign({}, state, { [url]: newPageState }); diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 9cf856f03a0..6602cda080c 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -1,21 +1,23 @@ +import { Injector } from '@angular/core'; import { Store } from '@ngrx/store'; -import { ObjectUpdatesService } from './object-updates.service'; +import { createMockStore } from '@ngrx/store/testing'; +import { of as observableOf } from 'rxjs'; + +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { CoreState } from '../../core-state.model'; +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; import { DiscardObjectUpdatesAction, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, - SetEditableFieldUpdateAction + SetEditableFieldUpdateAction, } from './object-updates.actions'; -import { of as observableOf } from 'rxjs'; -import { Notification } from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { Injector } from '@angular/core'; -import { FieldChangeType } from './field-change-type.model'; -import { CoreState } from '../../core-state.model'; +import { ObjectUpdatesService } from './object-updates.service'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -31,7 +33,7 @@ describe('ObjectUpdatesService', () => { const fieldUpdates = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, }; const modDate = new Date(2010, 2, 11); @@ -46,15 +48,15 @@ describe('ObjectUpdatesService', () => { }; patchOperationService = jasmine.createSpyObj('patchOperationService', { - fieldUpdatesToPatchOperations: [] + fieldUpdatesToPatchOperations: [], }); const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationService + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationService, }; - store = new Store(undefined, undefined, undefined); + store = createMockStore({}); spyOn(store, 'dispatch'); injector = jasmine.createSpyObj('injector', { - get: patchOperationService + get: patchOperationService, }); service = new ObjectUpdatesService(store, injector); @@ -80,7 +82,7 @@ describe('ObjectUpdatesService', () => { const expectedResult = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, }; result$.subscribe((result) => { @@ -96,7 +98,7 @@ describe('ObjectUpdatesService', () => { const expectedResult = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, - [identifiable2.uuid]: { field: identifiable2, changeType: undefined } + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, }; result$.subscribe((result) => { diff --git a/src/app/core/data/object-updates/object-updates.service.stub.ts b/src/app/core/data/object-updates/object-updates.service.stub.ts new file mode 100644 index 00000000000..c41728a338e --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.stub.ts @@ -0,0 +1,28 @@ +export class ObjectUpdatesServiceStub { + + initialize = jasmine.createSpy('initialize'); + saveFieldUpdate = jasmine.createSpy('saveFieldUpdate'); + getObjectEntry = jasmine.createSpy('getObjectEntry'); + getFieldState = jasmine.createSpy('getFieldState'); + getFieldUpdates = jasmine.createSpy('getFieldUpdates'); + getFieldUpdatesExclusive = jasmine.createSpy('getFieldUpdatesExclusive'); + isValid = jasmine.createSpy('isValid'); + isValidPage = jasmine.createSpy('isValidPage'); + saveAddFieldUpdate = jasmine.createSpy('saveAddFieldUpdate'); + saveRemoveFieldUpdate = jasmine.createSpy('saveRemoveFieldUpdate'); + saveChangeFieldUpdate = jasmine.createSpy('saveChangeFieldUpdate'); + isSelectedVirtualMetadata = jasmine.createSpy('isSelectedVirtualMetadata'); + setSelectedVirtualMetadata = jasmine.createSpy('setSelectedVirtualMetadata'); + setEditableFieldUpdate = jasmine.createSpy('setEditableFieldUpdate'); + setValidFieldUpdate = jasmine.createSpy('setValidFieldUpdate'); + discardFieldUpdates = jasmine.createSpy('discardFieldUpdates'); + discardAllFieldUpdates = jasmine.createSpy('discardAllFieldUpdates'); + reinstateFieldUpdates = jasmine.createSpy('reinstateFieldUpdates'); + removeSingleFieldUpdate = jasmine.createSpy('removeSingleFieldUpdate'); + getUpdateFields = jasmine.createSpy('getUpdateFields'); + hasUpdates = jasmine.createSpy('hasUpdates'); + isReinstatable = jasmine.createSpy('isReinstatable'); + getLastModified = jasmine.createSpy('getLastModified'); + createPatch = jasmine.createSpy('getPatch'); + +} diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 2fb6d47d31c..75b554d87b1 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,14 +1,37 @@ -import { Injectable, Injector } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { coreSelector } from '../../core.selectors'; import { - FieldState, - OBJECT_UPDATES_TRASH_PATH, - ObjectUpdatesEntry, - ObjectUpdatesState, - VirtualMetadataSource -} from './object-updates.reducer'; + Injectable, + Injector, +} from '@angular/core'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, +} from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { coreSelector } from '../../core.selectors'; +import { CoreState } from '../../core-state.model'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { FieldChangeType } from './field-change-type.model'; +import { FieldUpdates } from './field-updates.model'; +import { Identifiable } from './identifiable.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -17,24 +40,16 @@ import { RemoveFieldUpdateAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, - SetValidFieldUpdateAction + SetValidFieldUpdateAction, } from './object-updates.actions'; -import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { - hasNoValue, - hasValue, - isEmpty, - isNotEmpty, - hasValueOperator -} from '../../../shared/empty.util'; -import { INotification } from '../../../shared/notifications/models/notification.model'; -import { Operation } from 'fast-json-patch'; + FieldState, + OBJECT_UPDATES_TRASH_PATH, + ObjectUpdatesEntry, + ObjectUpdatesState, + VirtualMetadataSource, +} from './object-updates.reducer'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; -import { FieldUpdates } from './field-updates.model'; -import { FieldChangeType } from './field-change-type.model'; -import { CoreState } from '../../core-state.model'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -55,7 +70,7 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel /** * Service that dispatches and reads from the ObjectUpdates' state in the store */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class ObjectUpdatesService { constructor(private store: Store, private injector: Injector) { @@ -122,7 +137,7 @@ export class ObjectUpdatesService { fieldUpdates[uuid] = fieldUpdatesExclusive[uuid]; }); return fieldUpdates; - }) + }), ); }), ); @@ -139,16 +154,16 @@ export class ObjectUpdatesService { return objectUpdates.pipe( hasValueOperator(), map((objectEntry) => { - const fieldUpdates: FieldUpdates = {}; - for (const object of initialFields) { - let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; - if (isEmpty(fieldUpdate)) { - fieldUpdate = { field: object, changeType: undefined }; + const fieldUpdates: FieldUpdates = {}; + for (const object of initialFields) { + let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; + if (isEmpty(fieldUpdate)) { + fieldUpdate = { field: object, changeType: undefined }; + } + fieldUpdates[object.uuid] = fieldUpdate; } - fieldUpdates[object.uuid] = fieldUpdate; - } - return fieldUpdates; - })); + return fieldUpdates; + })); } /** @@ -161,7 +176,7 @@ export class ObjectUpdatesService { return fieldState$.pipe( filter((fieldState) => hasValue(fieldState)), map((fieldState) => fieldState.editable), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -175,7 +190,7 @@ export class ObjectUpdatesService { return fieldState$.pipe( filter((fieldState) => hasValue(fieldState)), map((fieldState) => fieldState.isValid), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -189,7 +204,7 @@ export class ObjectUpdatesService { map((entry: ObjectUpdatesEntry) => { return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0; }), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -198,8 +213,14 @@ export class ObjectUpdatesService { * @param url The page's URL for which the changes are saved * @param field An updated field for the page's object */ - saveAddFieldUpdate(url: string, field: Identifiable) { + saveAddFieldUpdate(url: string, field: Identifiable): Observable { + const update$: Observable = this.getFieldUpdatesExclusive(url, [field]).pipe( + filter((fieldUpdates: FieldUpdates) => fieldUpdates[field.uuid].changeType === FieldChangeType.ADD), + take(1), + map(() => true), + ); this.saveFieldUpdate(url, field, FieldChangeType.ADD); + return update$; } /** @@ -207,8 +228,14 @@ export class ObjectUpdatesService { * @param url The page's URL for which the changes are saved * @param field An updated field for the page's object */ - saveRemoveFieldUpdate(url: string, field: Identifiable) { + saveRemoveFieldUpdate(url: string, field: Identifiable): Observable { + const update$: Observable = this.getFieldUpdatesExclusive(url, [field]).pipe( + filter((fieldUpdates: FieldUpdates) => fieldUpdates[field.uuid].changeType === FieldChangeType.REMOVE), + take(1), + map(() => true), + ); this.saveFieldUpdate(url, field, FieldChangeType.REMOVE); + return update$; } /** @@ -234,7 +261,7 @@ export class ObjectUpdatesService { .pipe( select(virtualMetadataSourceSelector(url, relationship)), map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), - ); + ); } /** @@ -367,7 +394,7 @@ export class ObjectUpdatesService { patch = this.injector.get(entry.patchOperationService).fieldUpdatesToPatchOperations(entry.fieldUpdates); } return patch; - }) + }), ); } } diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts index db46426b79f..ddf38dd2bb1 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -1,8 +1,9 @@ -import { MetadataPatchOperationService } from './metadata-patch-operation.service'; import { Operation } from 'fast-json-patch'; + import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { FieldUpdates } from '../field-updates.model'; import { FieldChangeType } from '../field-change-type.model'; +import { FieldUpdates } from '../field-updates.model'; +import { MetadataPatchOperationService } from './metadata-patch-operation.service'; describe('MetadataPatchOperationService', () => { let service: MetadataPatchOperationService; @@ -23,13 +24,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -46,13 +47,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Added title', - place: 0 + place: 0, }), - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }); expected = [ - { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] } + { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -69,13 +70,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Changed title', - place: 0 + place: 0, }), - changeType: FieldChangeType.UPDATE - } + changeType: FieldChangeType.UPDATE, + }, }); expected = [ - { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } } + { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -92,31 +93,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/0' }, { op: 'remove', path: '/metadata/dc.title/0' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -133,31 +134,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/2' }, { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -174,31 +175,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -215,31 +216,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third changed title', - place: 2 + place: 2, }), - changeType: FieldChangeType.UPDATE + changeType: FieldChangeType.UPDATE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts index 33e9129a9d1..b6dccb759b2 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -1,21 +1,22 @@ -import { PatchOperationService } from './patch-operation.service'; -import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { Operation } from 'fast-json-patch'; import { Injectable } from '@angular/core'; -import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + import { hasValue } from '../../../../shared/empty.util'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldChangeType } from '../field-change-type.model'; +import { FieldUpdates } from '../field-updates.model'; import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model'; +import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model'; import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model'; -import { FieldUpdates } from '../field-updates.model'; -import { FieldChangeType } from '../field-change-type.model'; +import { PatchOperationService } from './patch-operation.service'; /** * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values * This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class MetadataPatchOperationService implements PatchOperationService { @@ -75,7 +76,7 @@ export class MetadataPatchOperationService implements PatchOperationService { const metadatum = update.field as MetadatumViewModel; const val = { value: metadatum.value, - language: metadatum.language + language: metadatum.language, }; let operation: MetadataPatchOperation; diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts index 7f9b1d772f4..9242290c6b7 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch add Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts index 962d53dfee4..d80ec16cd1a 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch move Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts index 61fbae1980b..efaf61f3814 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch remove Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts index e889bede0b7..c2d95812933 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch replace Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts index 171c1d2a54e..7e9c1087ff0 100644 --- a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts @@ -1,4 +1,5 @@ import { Operation } from 'fast-json-patch'; + import { FieldUpdates } from '../field-updates.model'; /** diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index 415bfe234ea..ef2819afd8f 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -1,13 +1,22 @@ -import { PageInfo } from '../shared/page-info.model'; -import { hasValue, isEmpty, hasNoValue, isUndefined } from '../../shared/empty.util'; -import { HALResource } from '../shared/hal-resource.model'; -import { HALLink } from '../shared/hal-link.model'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { + hasNoValue, + hasValue, + isEmpty, + isUndefined, +} from '../../shared/empty.util'; import { typedObject } from '../cache/builders/build-decorators'; -import { PAGINATED_LIST } from './paginated-list.resource-type'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { PageInfo } from '../shared/page-info.model'; import { ResourceType } from '../shared/resource-type'; import { excludeFromEquals } from '../utilities/equals.decorators'; -import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { PAGINATED_LIST } from './paginated-list.resource-type'; /** * Factory function for a paginated list @@ -45,7 +54,7 @@ export const buildPaginatedList = (pageInfo: PageInfo, page: T[], normalized } result._links = Object.assign({}, _links, pageInfo._links, { - page: pageLinks + page: pageLinks, }); if (!normalized || isUndefined(pageLinks)) { diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index fbebe75b2b5..9bf91121cc4 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,5 +1,5 @@ -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ParsedResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { RestRequest } from './rest-request.model'; export interface ResponseParsingService { diff --git a/src/app/core/data/primary-bitstream.service.spec.ts b/src/app/core/data/primary-bitstream.service.spec.ts index 00d6d7f03ce..6a9c89f7968 100644 --- a/src/app/core/data/primary-bitstream.service.spec.ts +++ b/src/app/core/data/primary-bitstream.service.spec.ts @@ -1,20 +1,30 @@ -import { ObjectCacheService } from '../cache/object-cache.service'; -import { RequestService } from './request.service'; -import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; + import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { PrimaryBitstreamService } from './primary-bitstream.service'; -import { BundleDataService } from './bundle-data.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { CreateRequest, DeleteRequest, PostRequest, PutRequest } from './request.models'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; -import { getTestScheduler } from 'jasmine-marbles'; -import { of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BundleDataService } from './bundle-data.service'; +import { PrimaryBitstreamService } from './primary-bitstream.service'; +import { + CreateRequest, + DeleteRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; describe('PrimaryBitstreamService', () => { let service: PrimaryBitstreamService; @@ -28,8 +38,8 @@ describe('PrimaryBitstreamService', () => { const bitstream = Object.assign(new Bitstream(), { uuid: 'fake-bitstream', _links: { - self: { href: 'fake-bitstream-self' } - } + self: { href: 'fake-bitstream-self' }, + }, }); const bundle = Object.assign(new Bundle(), { @@ -37,21 +47,21 @@ describe('PrimaryBitstreamService', () => { _links: { self: { href: 'fake-bundle-self' }, primaryBitstream: { href: 'fake-primary-bitstream-self' }, - } + }, }); const url = 'fake-bitstream-url'; beforeEach(() => { objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); requestService = getMockRequestService(); halService = Object.assign(new HALEndpointServiceStub(url)); rdbService = getMockRemoteDataBuildService(); notificationService = new NotificationsServiceStub() as any; - bundleDataService = jasmine.createSpyObj('bundleDataService', {'findByHref': createSuccessfulRemoteDataObject$(bundle)}); + bundleDataService = jasmine.createSpyObj('bundleDataService', { 'findByHref': createSuccessfulRemoteDataObject$(bundle) }); service = new PrimaryBitstreamService(requestService, rdbService, objectCache, halService, notificationService, bundleDataService); }); @@ -96,7 +106,7 @@ describe('PrimaryBitstreamService', () => { expect((service as any).createAndSendRequest).toHaveBeenCalledWith( PostRequest, bundle._links.primaryBitstream.href, - bitstream.self + bitstream.self, ); }); }); @@ -113,7 +123,7 @@ describe('PrimaryBitstreamService', () => { expect((service as any).createAndSendRequest).toHaveBeenCalledWith( PutRequest, bundle._links.primaryBitstream.href, - bitstream.self + bitstream.self, ); }); }); @@ -121,12 +131,12 @@ describe('PrimaryBitstreamService', () => { const testBundle = Object.assign(new Bundle(), { _links: { self: { - href: 'test-href' + href: 'test-href', }, primaryBitstream: { - href: 'test-primaryBitstream-href' - } - } + href: 'test-primaryBitstream-href', + }, + }, }); describe('when the delete request succeeds', () => { diff --git a/src/app/core/data/primary-bitstream.service.ts b/src/app/core/data/primary-bitstream.service.ts index 488cb5d22e7..a5367e67ed4 100644 --- a/src/app/core/data/primary-bitstream.service.ts +++ b/src/app/core/data/primary-bitstream.service.ts @@ -1,20 +1,28 @@ -import { Bitstream } from '../shared/bitstream.model'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; +import { + Observable, + switchMap, +} from 'rxjs'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable, switchMap } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { Bundle } from '../shared/bundle.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { HttpHeaders } from '@angular/common/http'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { PutRequest, PostRequest, DeleteRequest } from './request.models'; -import { getAllCompletedRemoteData } from '../shared/operators'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NoContent } from '../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../shared/operators'; import { BundleDataService } from './bundle-data.service'; +import { RemoteData } from './remote-data'; +import { + DeleteRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -63,8 +71,8 @@ export class PrimaryBitstreamService { requestId, endpointURL, primaryBitstreamSelfLink, - this.getHttpOptions() - ); + this.getHttpOptions(), + ); this.requestService.send(request); @@ -81,7 +89,7 @@ export class PrimaryBitstreamService { return this.createAndSendRequest( PostRequest, bundle._links.primaryBitstream.href, - primaryBitstream.self + primaryBitstream.self, ) as Observable>; } @@ -95,7 +103,7 @@ export class PrimaryBitstreamService { return this.createAndSendRequest( PutRequest, bundle._links.primaryBitstream.href, - primaryBitstream.self + primaryBitstream.self, ) as Observable>; } @@ -107,12 +115,12 @@ export class PrimaryBitstreamService { delete(bundle: Bundle): Observable> { return this.createAndSendRequest( DeleteRequest, - bundle._links.primaryBitstream.href + bundle._links.primaryBitstream.href, ).pipe( getAllCompletedRemoteData(), switchMap((rd: RemoteData) => { return this.bundleDataService.findByHref(bundle.self, rd.hasFailed); - }) + }), ); } diff --git a/src/app/core/data/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts index 88e5bd57915..217567776c2 100644 --- a/src/app/core/data/processes/process-data.service.spec.ts +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -6,14 +6,197 @@ * http://www.dspace.org/license/ */ -import { testFindAllDataImplementation } from '../base/find-all-data.spec'; -import { ProcessDataService } from './process-data.service'; +import { + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { ReducerManager } from '@ngrx/store'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessStatus } from '../../../process-page/processes/process-status.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from '../base/delete-data.spec'; +import { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { BitstreamFormatDataService } from '../bitstream-format-data.service'; +import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; +import { + ProcessDataService, + TIMER_FACTORY, +} from './process-data.service'; describe('ProcessDataService', () => { + let testScheduler; + + const mockTimer = (fn: () => any, interval: number) => { + fn(); + return 555; + }; + describe('composition', () => { - const initService = () => new ProcessDataService(null, null, null, null, null, null); + const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null); testFindAllDataImplementation(initService); testDeleteDataImplementation(initService); + testSearchDataImplementation(initService); + }); + + let requestService = getMockRequestService(); + let processDataService; + let remoteDataBuildService; + + describe('autoRefreshUntilCompletion', () => { + beforeEach(waitForAsync(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: null }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ], + }); + + processDataService = TestBed.inject(ProcessDataService); + spyOn(processDataService, 'invalidateByHref'); + })); + + it('should not do any polling when the process is already completed', () => { + testScheduler.run(({ cold, expectObservable }) => { + let completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('c', { + 'c': completedProcessRD, + }), + ); + + let process$ = processDataService.autoRefreshUntilCompletion('instantly'); + expectObservable(process$).toBe('c', { + c: completedProcessRD, + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).not.toHaveBeenCalled(); + }); + + it('should poll until a process completes', () => { + testScheduler.run(({ cold, expectObservable }) => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123', + }, + }, + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + const completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess); + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD, + }), + ); + + let process$ = processDataService.autoRefreshUntilCompletion('foo', 100); + expectObservable(process$).toBe('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD, + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1); + }); + }); + + describe('autoRefreshingSearchBy', () => { + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ], + }); + + processDataService = TestBed.inject(ProcessDataService); + })); + + it('should refresh after the specified interval', fakeAsync(() => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123', + }, + }, + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + + const runningProcessPagination: PaginatedList = Object.assign(new PaginatedList(), { + page: [runningProcess], + _links: { + self: { + href: 'https://rest.api/processesList/456', + }, + }, + }); + + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcessPagination); + + spyOn(processDataService, 'searchBy').and.returnValue( + of(runningProcessRD), + ); + + expect(processDataService.searchBy).toHaveBeenCalledTimes(0); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(0); + + let sub = processDataService.autoRefreshingSearchBy('id', 'byProperty', new FindListOptions(), 200).subscribe(); + expect(processDataService.searchBy).toHaveBeenCalledTimes(1); + + tick(250); + + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(1); + + sub.unsubscribe(); + })); }); }); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 3bf34eb650d..b39a500c804 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,30 +1,66 @@ -import { Injectable } from '@angular/core'; -import { RequestService } from '../request.service'; +import { + Inject, + Injectable, + InjectionToken, + NgZone, +} from '@angular/core'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + find, + switchMap, +} from 'rxjs/operators'; +import { ProcessStatus } from 'src/app/process-page/processes/process-status.model'; + +import { Process } from '../../../process-page/processes/process.model'; +import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { Process } from '../../../process-page/processes/process.model'; -import { PROCESS } from '../../../process-page/processes/process.resource-type'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; -import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; -import { RemoteData } from '../remote-data'; -import { BitstreamDataService } from '../bitstream-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NoContent } from '../../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../../shared/operators'; +import { + DeleteData, + DeleteDataImpl, +} from '../base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from '../base/find-all-data'; import { IdentifiableDataService } from '../base/identifiable-data.service'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; +import { + SearchData, + SearchDataImpl, +} from '../base/search-data'; +import { BitstreamDataService } from '../bitstream-data.service'; 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'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; -@Injectable() -@dataService(PROCESS) -export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { +/** + * Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during + * testing. (fakeAsync isn't working for this case) + */ +export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', { + providedIn: 'root', + factory: () => setTimeout, +}); + +@Injectable({ providedIn: 'root' }) +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData, SearchData { private findAllData: FindAllData; private deleteData: DeleteData; + private searchData: SearchData; + protected activelyBeingPolled: Map = new Map(); + protected subs: Map = new Map(); constructor( protected requestService: RequestService, @@ -33,11 +69,30 @@ export class ProcessDataService extends IdentifiableDataService impleme protected halService: HALEndpointService, protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, + protected zone: NgZone, + @Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout, ) { super('processes', requestService, rdbService, objectCache, halService); this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return true if the given process has the given status + * @protected + */ + protected static statusIs(process: Process, status: ProcessStatus): boolean { + return hasValue(process) && process.processStatus === status; + } + + /** + * Return true if the given process has the status COMPLETED or FAILED + */ + public static hasCompletedOrFailed(process: Process): boolean { + return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) || + ProcessDataService.statusIs(process, ProcessStatus.FAILED); } /** @@ -46,7 +101,7 @@ export class ProcessDataService extends IdentifiableDataService impleme */ getFilesEndpoint(processId: string): Observable { return this.getBrowseEndpoint().pipe( - switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)) + switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)), ); } @@ -77,6 +132,71 @@ export class ProcessDataService extends IdentifiableDataService impleme return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true. + * @param reRequestOnStale Whether the request should automatically be re- + * requested after the response becomes stale. + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * @param id The id for this auto-refreshing search. Used to stop + * auto-refreshing afterwards, and ensure we're not + * auto-refreshing the same thing multiple times. + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param pollingIntervalInMs The interval by which the search will be repeated + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes every interval + */ + autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig[]): Observable>> { + + const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + + const sub = result$.pipe( + filter(() => + !this.activelyBeingPolled.has(id), + ), + ).subscribe((processListRd: RemoteData>) => { + this.clearCurrentTimeout(id); + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(id); + this.requestService.setStaleByHrefSubstring(processListRd.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(id, nextTimeout); + }); + + this.subs.set(id, sub); + + return result$; + } + + /** + * Stop auto-refreshing the request with the given id + * @param id the id of the request to stop automatically refreshing + */ + stopAutoRefreshing(id: string) { + this.clearCurrentTimeout(id); + if (hasValue(this.subs.get(id))) { + this.subs.get(id).unsubscribe(); + this.subs.delete(id); + } + } + /** * Delete an existing object on the server * @param objectId The id of the object to be removed @@ -101,4 +221,74 @@ export class ProcessDataService extends IdentifiableDataService impleme public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Clear the timeout for the given id, if that timeout exists + * @protected + */ + protected clearCurrentTimeout(id: string): void { + const timeout = this.activelyBeingPolled.get(id); + if (hasValue(timeout)) { + clearTimeout(timeout); + } + this.activelyBeingPolled.delete(id); + } + + /** + * Poll the process with the given ID, using the given interval, until that process either + * completes successfully or fails + * + * Return an Observable for the Process. Note that this will also emit while the + * process is still running. It will only emit again when the process (not the RemoteData!) changes + * status. That makes it more convenient to retrieve that process for a component: you can replace + * a findByID call with this method, rather than having to do a separate findById, and then call + * this method + * + * @param processId The ID of the {@link Process} to poll + * @param pollingIntervalInMs The interval for how often the request needs to be polled + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be + * automatically resolved + */ + public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig[]): Observable> { + const process$: Observable> = this.findById(processId, true, true, ...linksToFollow) + .pipe( + getAllCompletedRemoteData(), + ); + + // Create a subscription that marks the data as stale if the process hasn't been completed and + // the polling interval time has been exceeded. + const sub = process$.pipe( + filter((processRD: RemoteData) => + !ProcessDataService.hasCompletedOrFailed(processRD.payload) && + !this.activelyBeingPolled.has(processId), + ), + ).subscribe((processRD: RemoteData) => { + this.clearCurrentTimeout(processId); + if (processRD.hasSucceeded) { + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(processId); + this.invalidateByHref(processRD.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(processId, nextTimeout); + } + }); + + this.subs.set(processId, sub); + + // When the process completes create a one off subscription (the `find` completes the + // observable) that unsubscribes the previous one, removes the processId from the list of + // processes being polled and clears any running timeouts + process$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)), + ).subscribe(() => { + this.stopAutoRefreshing(processId); + }); + + return process$.pipe( + distinctUntilChanged((previous: RemoteData, current: RemoteData) => + previous.payload?.processStatus === current.payload?.processStatus, + ), + ); + } } diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index d9c92cb1d21..e61da4db636 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,34 +1,38 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; +import { Script } from '../../../process-page/scripts/script.model'; +import { hasValue } from '../../../shared/empty.util'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { Script } from '../../../process-page/scripts/script.model'; -import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; -import { map, take } from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { + FindAllData, + FindAllDataImpl, +} from '../base/find-all-data'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; import { MultipartPostRequest } from '../request.models'; import { RequestService } from '../request.service'; -import { Observable } from 'rxjs'; -import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; -import { Process } from '../../../process-page/processes/process.model'; -import { hasValue } from '../../../shared/empty.util'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; import { RestRequest } from '../rest-request.model'; -import { IdentifiableDataService } from '../base/identifiable-data.service'; -import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { PaginatedList } from '../paginated-list.model'; -import { dataService } from '../base/data-service.decorator'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; export const BATCH_IMPORT_SCRIPT_NAME = 'import'; export const BATCH_EXPORT_SCRIPT_NAME = 'export'; -@Injectable() -@dataService(SCRIPT) +@Injectable({ providedIn: 'root' }) export class ScriptDataService extends IdentifiableDataService'">
`, + standalone: true, + imports: [ MarkdownDirective ], +}) +class TestComponent {} + +describe('MarkdownDirective', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: MathService, useClass: MockMathService }, + ], + }).compileComponents(); + spyOn(MarkdownDirective.prototype, 'render'); + fixture = TestBed.createComponent(TestComponent); + }); + + it('should call render method', () => { + fixture.detectChanges(); + expect(MarkdownDirective.prototype.render).toHaveBeenCalled(); + }); + +}); + +describe('MarkdownDirective sanitization with markdown disabled', () => { + let fixture: ComponentFixture; + let divEl: DebugElement; + // Disable markdown + environment.markdown.enabled = false; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: MathService, useClass: MockMathService }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(TestComponent); + divEl = fixture.debugElement.query(By.css('div')); + + }); + + it('should sanitize the script element out of innerHTML (markdown disabled)',() => { + fixture.detectChanges(); + divEl = fixture.debugElement.query(By.css('div')); + expect(divEl.nativeElement.innerHTML).toEqual('test'); + }); + +}); + +describe('MarkdownDirective sanitization with markdown enabled', () => { + let fixture: ComponentFixture; + let divEl: DebugElement; + // Enable markdown + environment.markdown.enabled = true; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: MathService, useClass: MockMathService }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(TestComponent); + divEl = fixture.debugElement.query(By.css('div')); + + }); + + it('should sanitize the script element out of innerHTML (markdown enabled)',() => { + fixture.detectChanges(); + divEl = fixture.debugElement.query(By.css('div')); + expect(divEl.nativeElement.innerHTML).toEqual('test'); + }); + +}); diff --git a/src/app/shared/utils/markdown.directive.ts b/src/app/shared/utils/markdown.directive.ts new file mode 100644 index 00000000000..de13acb3036 --- /dev/null +++ b/src/app/shared/utils/markdown.directive.ts @@ -0,0 +1,97 @@ +import { + Directive, + ElementRef, + Inject, + InjectionToken, + Input, + OnDestroy, + OnInit, + SecurityContext, +} from '@angular/core'; +import { + DomSanitizer, + SafeHtml, +} from '@angular/platform-browser'; +import { Subject } from 'rxjs'; +import { + filter, + take, + takeUntil, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { MathService } from '../../core/shared/math.service'; +import { isEmpty } from '../empty.util'; + +const markdownItLoader = async () => (await import('markdown-it')).default; +type LazyMarkdownIt = ReturnType; +const MARKDOWN_IT = new InjectionToken( + 'Lazily loaded MarkdownIt', + { providedIn: 'root', factory: markdownItLoader }, +); + +@Directive({ + selector: '[dsMarkdown]', + standalone: true, +}) +export class MarkdownDirective implements OnInit, OnDestroy { + + @Input() dsMarkdown: string; + private alive$ = new Subject(); + + el: HTMLElement; + + constructor( + @Inject(MARKDOWN_IT) private markdownIt: LazyMarkdownIt, + protected sanitizer: DomSanitizer, + private mathService: MathService, + private elementRef: ElementRef) { + this.el = elementRef.nativeElement; + } + + ngOnInit() { + this.render(this.dsMarkdown); + } + + async render(value: string, forcePreview = false): Promise { + if (isEmpty(value) || (!environment.markdown.enabled && !forcePreview)) { + this.el.innerHTML = this.sanitizer.sanitize(SecurityContext.HTML, value); + return; + } else { + if (environment.markdown.mathjax) { + this.renderMathjaxThenMarkdown(value); + } else { + this.renderMarkdown(value); + } + } + } + + private renderMathjaxThenMarkdown(value: string) { + const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, value); + this.el.innerHTML = sanitized; + this.mathService.ready().pipe( + filter((ready) => ready), + take(1), + takeUntil(this.alive$), + ).subscribe(() => { + this.mathService.render(this.el)?.then(_ => { + this.renderMarkdown(this.el.innerHTML, true); + }); + }); + } + + private async renderMarkdown(value: string, alreadySanitized = false) { + const MarkdownIt = await this.markdownIt; + const md = new MarkdownIt({ + html: true, + linkify: true, + }); + + const html = alreadySanitized ? md.render(value) : this.sanitizer.sanitize(SecurityContext.HTML, md.render(value)); + this.el.innerHTML = html; + } + + ngOnDestroy() { + this.alive$.next(false); + } +} diff --git a/src/app/shared/utils/markdown.pipe.spec.ts b/src/app/shared/utils/markdown.pipe.spec.ts deleted file mode 100644 index 50f772097db..00000000000 --- a/src/app/shared/utils/markdown.pipe.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { MarkdownPipe } from './markdown.pipe'; -import { TestBed } from '@angular/core/testing'; -import { APP_CONFIG } from '../../../config/app-config.interface'; -import { environment } from '../../../environments/environment'; - -describe('Markdown Pipe', () => { - - let markdownPipe: MarkdownPipe; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MarkdownPipe, - { - provide: APP_CONFIG, - useValue: Object.assign(environment, { - markdown: { - enabled: true, - mathjax: true, - } - }) - }, - ], - }).compileComponents(); - - markdownPipe = TestBed.inject(MarkdownPipe); - }); - - it('should render markdown', async () => { - await testTransform( - '# Header', - '

Header

' - ); - }); - - it('should render mathjax', async () => { - await testTransform( - '$\\sqrt{2}^2$', - '.*' - ); - }); - - it('should render regular links', async () => { - await testTransform( - 'DSpace', - 'DSpace' - ); - }); - - it('should not render javascript links', async () => { - await testTransform( - 'exploit', - 'exploit' - ); - }); - - async function testTransform(input: string, output: string) { - expect( - await markdownPipe.transform(input) - ).toMatch( - new RegExp('.*' + output + '.*') - ); - } -}); diff --git a/src/app/shared/utils/metadatafield-validator.directive.ts b/src/app/shared/utils/metadatafield-validator.directive.ts index 8725246eebe..3112d4870be 100644 --- a/src/app/shared/utils/metadatafield-validator.directive.ts +++ b/src/app/shared/utils/metadatafield-validator.directive.ts @@ -1,7 +1,24 @@ -import { Directive, Injectable } from '@angular/core'; -import { AbstractControl, AsyncValidator, NG_VALIDATORS, ValidationErrors } from '@angular/forms'; -import { map, switchMap, take } from 'rxjs/operators'; -import { of as observableOf, timer as observableTimer, Observable } from 'rxjs'; +import { + Directive, + Injectable, +} from '@angular/core'; +import { + AbstractControl, + AsyncValidator, + NG_VALIDATORS, + ValidationErrors, +} from '@angular/forms'; +import { + Observable, + of as observableOf, + timer as observableTimer, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + import { MetadataFieldDataService } from '../../core/data/metadata-field-data.service'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; @@ -15,8 +32,9 @@ import { getFirstSucceededRemoteData } from '../../core/shared/operators'; selector: '[ngModel][dsMetadataFieldValidator]', // We add our directive to the list of existing validators providers: [ - { provide: NG_VALIDATORS, useExisting: MetadataFieldValidator, multi: true } - ] + { provide: NG_VALIDATORS, useExisting: MetadataFieldValidator, multi: true }, + ], + standalone: true, }) @Injectable({ providedIn: 'root' }) export class MetadataFieldValidator implements AsyncValidator { @@ -48,13 +66,13 @@ export class MetadataFieldValidator implements AsyncValidator { } else if (matchingFieldRD.payload.pageInfo.totalElements === 1) { return null; } - }) + }), ); res.pipe(take(1)).subscribe(); return res; - }) + }), ); resTimer.pipe(take(1)).subscribe(); return resTimer; diff --git a/src/app/shared/utils/object-keys-pipe.ts b/src/app/shared/utils/object-keys-pipe.ts index 0d6a2c82229..8ade3f236b3 100644 --- a/src/app/shared/utils/object-keys-pipe.ts +++ b/src/app/shared/utils/object-keys-pipe.ts @@ -1,6 +1,12 @@ -import { PipeTransform, Pipe } from '@angular/core'; +import { + Pipe, + PipeTransform, +} from '@angular/core'; -@Pipe({name: 'dsObjectKeys'}) +@Pipe({ + name: 'dsObjectKeys', + standalone: true, +}) /** * Pipe for parsing all keys of an object to an array of key-value pairs */ diff --git a/src/app/shared/utils/object-ngfor.pipe.ts b/src/app/shared/utils/object-ngfor.pipe.ts index 982e3342e00..c52426fef49 100644 --- a/src/app/shared/utils/object-ngfor.pipe.ts +++ b/src/app/shared/utils/object-ngfor.pipe.ts @@ -1,4 +1,7 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { + Pipe, + PipeTransform, +} from '@angular/core'; /** * Pipe that allows to iterate over an object and to access to entry key and value : @@ -9,10 +12,11 @@ import { Pipe, PipeTransform } from '@angular/core'; * */ @Pipe({ - name: 'dsObjNgFor' + name: 'dsObjNgFor', + standalone: true, }) export class ObjNgFor implements PipeTransform { transform(value: any, args: any[] = null): any { - return Object.keys(value).map((key) => Object.assign({ key }, {value: value[key]})); + return Object.keys(value).map((key) => Object.assign({ key }, { value: value[key] })); } } diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts index 5fab8bf8419..6b862afff95 100644 --- a/src/app/shared/utils/object-values-pipe.ts +++ b/src/app/shared/utils/object-values-pipe.ts @@ -1,9 +1,14 @@ -import { PipeTransform, Pipe } from '@angular/core'; +import { + Pipe, + PipeTransform, +} from '@angular/core'; + import { isNotEmpty } from '../empty.util'; @Pipe({ name: 'dsObjectValues', - pure: true + pure: true, + standalone: true, }) /** * Pipe for parsing all values of an object to an array of values diff --git a/src/app/shared/utils/relation-query.utils.spec.ts b/src/app/shared/utils/relation-query.utils.spec.ts index f40c3314974..1f2a725a9f7 100644 --- a/src/app/shared/utils/relation-query.utils.spec.ts +++ b/src/app/shared/utils/relation-query.utils.spec.ts @@ -1,4 +1,7 @@ -import { getFilterByRelation, getQueryByRelations } from './relation-query.utils'; +import { + getFilterByRelation, + getQueryByRelations, +} from './relation-query.utils'; describe('Relation Query Utils', () => { const relationtype = 'isAuthorOfPublication'; diff --git a/src/app/shared/utils/relation-query.utils.ts b/src/app/shared/utils/relation-query.utils.ts index 62a69075fcb..158744e78c3 100644 --- a/src/app/shared/utils/relation-query.utils.ts +++ b/src/app/shared/utils/relation-query.utils.ts @@ -1,5 +1,9 @@ -import { followLink, FollowLinkConfig } from './follow-link-config.model'; +import { Item } from '../../core/shared/item.model'; import { Relationship } from '../../core/shared/item-relationships/relationship.model'; +import { + followLink, + FollowLinkConfig, +} from './follow-link-config.model'; /** * Get the query for looking up items by relation type @@ -21,19 +25,22 @@ export function getFilterByRelation(relationType: string, itemUUID: string): str } /** - * Creates links to follow for the leftItem and rightItem. Links will include - * @param showThumbnail thumbnail image configuration - * @returns followLink array + * Creates links to follow for the leftItem and rightItem. Optionally additional links for `thumbnail` & `accessStatus` + * can be embedded as well. + * + * @param showThumbnail Whether the `thumbnail` needs to be embedded on the {@link Item} + * @param showAccessStatus Whether the `accessStatus` needs to be embedded on the {@link Item} */ -export function itemLinksToFollow(showThumbnail: boolean): FollowLinkConfig[] { - let linksToFollow: FollowLinkConfig[]; +export function itemLinksToFollow(showThumbnail: boolean, showAccessStatus: boolean): FollowLinkConfig[] { + const conditionalLinksToFollow: FollowLinkConfig[] = []; if (showThumbnail) { - linksToFollow = [ - followLink('leftItem',{}, followLink('thumbnail')), - followLink('rightItem',{}, followLink('thumbnail')) - ]; - } else { - linksToFollow = [followLink('leftItem'), followLink('rightItem')]; + conditionalLinksToFollow.push(followLink('thumbnail')); } - return linksToFollow; + if (showAccessStatus) { + conditionalLinksToFollow.push(followLink('accessStatus')); + } + return [ + followLink('leftItem', undefined, ...conditionalLinksToFollow), + followLink('rightItem', undefined, ...conditionalLinksToFollow), + ]; } diff --git a/src/app/shared/utils/require-file.validator.ts b/src/app/shared/utils/require-file.validator.ts index ef41ad43094..6825f058dd1 100644 --- a/src/app/shared/utils/require-file.validator.ts +++ b/src/app/shared/utils/require-file.validator.ts @@ -1,22 +1,27 @@ -import {Directive} from '@angular/core'; -import {NG_VALIDATORS, Validator, UntypedFormControl} from '@angular/forms'; +import { Directive } from '@angular/core'; +import { + NG_VALIDATORS, + UntypedFormControl, + Validator, +} from '@angular/forms'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[requireFile]', - providers: [ - { provide: NG_VALIDATORS, useExisting: FileValidator, multi: true }, - ] + selector: '[requireFile]', + providers: [ + { provide: NG_VALIDATORS, useExisting: FileValidator, multi: true }, + ], + standalone: true, }) /** * Validator directive to validate if a file is selected */ export class FileValidator implements Validator { - static validate(c: UntypedFormControl): {[key: string]: any} { - return c.value == null || c.value.length === 0 ? { required : true } : null; - } + static validate(c: UntypedFormControl): {[key: string]: any} { + return c.value == null || c.value.length === 0 ? { required : true } : null; + } - validate(c: UntypedFormControl): {[key: string]: any} { - return FileValidator.validate(c); - } + validate(c: UntypedFormControl): {[key: string]: any} { + return FileValidator.validate(c); + } } diff --git a/src/app/shared/utils/route.utils.spec.ts b/src/app/shared/utils/route.utils.spec.ts index 7ec6c879d85..aa91a886dbe 100644 --- a/src/app/shared/utils/route.utils.spec.ts +++ b/src/app/shared/utils/route.utils.spec.ts @@ -7,12 +7,12 @@ describe('Route Utils', () => { primary: { segments: [ { path: 'test' }, - { path: 'path' } - ] - } + { path: 'path' }, + ], + }, - } - } + }, + }, }; const router = { parseUrl: () => urlTree } as any; it('Should return the correct current path based on the router', () => { diff --git a/src/app/shared/utils/route.utils.ts b/src/app/shared/utils/route.utils.ts index 8f7bb9120de..9f45b7ec239 100644 --- a/src/app/shared/utils/route.utils.ts +++ b/src/app/shared/utils/route.utils.ts @@ -1,6 +1,10 @@ -import { ActivatedRouteSnapshot, Router } from '@angular/router'; -import { hasValue } from '../empty.util'; +import { + ActivatedRouteSnapshot, + Router, +} from '@angular/router'; + import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { hasValue } from '../empty.util'; /** * Util function to retrieve the current path (without query parameters) the user is on diff --git a/src/app/shared/utils/safe-url-pipe.ts b/src/app/shared/utils/safe-url-pipe.ts index 3f35ed92627..f8bc7288298 100644 --- a/src/app/shared/utils/safe-url-pipe.ts +++ b/src/app/shared/utils/safe-url-pipe.ts @@ -1,4 +1,7 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { + Pipe, + PipeTransform, +} from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; /** @@ -6,10 +9,13 @@ import { DomSanitizer } from '@angular/platform-browser'; * only use this when you are sure the URL is indeed safe */ -@Pipe({ name: 'dsSafeUrl' }) +@Pipe({ + name: 'dsSafeUrl', + standalone: true, +}) export class SafeUrlPipe implements PipeTransform { constructor(private domSanitizer: DomSanitizer) { } transform(url) { - return this.domSanitizer.bypassSecurityTrustResourceUrl(url); + return url == null ? null : this.domSanitizer.bypassSecurityTrustResourceUrl(url); } } diff --git a/src/app/shared/utils/short-number.pipe.spec.ts b/src/app/shared/utils/short-number.pipe.spec.ts index 99fb246f54f..9fda54f4706 100644 --- a/src/app/shared/utils/short-number.pipe.spec.ts +++ b/src/app/shared/utils/short-number.pipe.spec.ts @@ -9,7 +9,7 @@ describe('ShortNumber Pipe', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ - ShortNumberPipe + ShortNumberPipe, ], }).compileComponents(); @@ -19,78 +19,78 @@ describe('ShortNumber Pipe', () => { it('should not transform with an invalid number', async () => { await testTransform( 'tre', - 'tre' + 'tre', ); }); it('should not transform with an empty string', async () => { await testTransform( '', - '' + '', ); }); it('should not transform with zero', async () => { await testTransform( 0, - '0' + '0', ); }); it('should render 1K', async () => { await testTransform( '1000', - '1K' + '1K', ); }); it('should render 1K', async () => { await testTransform( 1000, - '1K' + '1K', ); }); it('should render 19.3K', async () => { await testTransform( 19300, - '19.3K' + '19.3K', ); }); it('should render 1M', async () => { await testTransform( 1000000, - '1M' + '1M', ); }); it('should render 1B', async () => { await testTransform( 1000000000, - '1B' + '1B', ); }); it('should render 1T', async () => { await testTransform( 1000000000000, - '1T' + '1T', ); }); it('should render 1Q', async () => { await testTransform( 1000000000000000, - '1Q' + '1Q', ); }); async function testTransform(input: any, output: string) { expect( - await shortNumberPipe.transform(input) + await shortNumberPipe.transform(input), ).toMatch( - output + output, ); } }); diff --git a/src/app/shared/utils/short-number.pipe.ts b/src/app/shared/utils/short-number.pipe.ts index e4d5cf83562..f0b458755d0 100644 --- a/src/app/shared/utils/short-number.pipe.ts +++ b/src/app/shared/utils/short-number.pipe.ts @@ -1,9 +1,14 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { + Pipe, + PipeTransform, +} from '@angular/core'; + import { isEmpty } from '../empty.util'; @Pipe({ - name: 'dsShortNumber' + name: 'dsShortNumber', + standalone: true, }) export class ShortNumberPipe implements PipeTransform { @@ -19,11 +24,11 @@ export class ShortNumberPipe implements PipeTransform { let key = ''; const powers = [ - {key: 'Q', value: Math.pow(10, 15)}, - {key: 'T', value: Math.pow(10, 12)}, - {key: 'B', value: Math.pow(10, 9)}, - {key: 'M', value: Math.pow(10, 6)}, - {key: 'K', value: 1000} + { key: 'Q', value: Math.pow(10, 15) }, + { key: 'T', value: Math.pow(10, 12) }, + { key: 'B', value: Math.pow(10, 9) }, + { key: 'M', value: Math.pow(10, 6) }, + { key: 'K', value: 1000 }, ]; for (let i = 0; i < powers.length; i++) { diff --git a/src/app/shared/utils/split.pipe.ts b/src/app/shared/utils/split.pipe.ts new file mode 100644 index 00000000000..c84e7895f18 --- /dev/null +++ b/src/app/shared/utils/split.pipe.ts @@ -0,0 +1,21 @@ +import { + Pipe, + PipeTransform, +} from '@angular/core'; + +/** + * Custom pipe to split a string into an array of substrings based on a specified separator. + * @param value - The string to be split. + * @param separator - The separator used to split the string. + * @returns An array of substrings. + */ +@Pipe({ + name: 'dsSplit', + standalone: true, +}) +export class SplitPipe implements PipeTransform { + transform(value: string, separator: string): string[] { + return value.split(separator); + } + +} diff --git a/src/app/shared/utils/truncate.pipe.ts b/src/app/shared/utils/truncate.pipe.ts index f841535bb81..2955be8f5d1 100644 --- a/src/app/shared/utils/truncate.pipe.ts +++ b/src/app/shared/utils/truncate.pipe.ts @@ -1,4 +1,8 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { + Pipe, + PipeTransform, +} from '@angular/core'; + import { hasValue } from '../empty.util'; /** @@ -6,7 +10,8 @@ import { hasValue } from '../empty.util'; * Default value: 10 */ @Pipe({ - name: 'dsTruncate' + name: 'dsTruncate', + standalone: true, }) export class TruncatePipe implements PipeTransform { diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts index 164ac7885e3..da3b94d3031 100644 --- a/src/app/shared/utils/validator.functions.ts +++ b/src/app/shared/utils/validator.functions.ts @@ -1,4 +1,8 @@ -import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { + AbstractControl, + ValidatorFn, +} from '@angular/forms'; + import { isNotEmpty } from '../empty.util'; /** diff --git a/src/app/shared/utils/var.directive.ts b/src/app/shared/utils/var.directive.ts index 497fc91a8bb..eaa8a6fdc1a 100644 --- a/src/app/shared/utils/var.directive.ts +++ b/src/app/shared/utils/var.directive.ts @@ -1,8 +1,14 @@ -import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; +import { + Directive, + Input, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; /* eslint-disable @angular-eslint/directive-selector */ @Directive({ selector: '[ngVar]', + standalone: true, }) export class VarDirective { @Input() diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.html b/src/app/shared/view-mode-switch/view-mode-switch.component.html index 45d219eb916..74ee22533d4 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.html +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.html @@ -1,35 +1,38 @@
diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index 248a8433161..e4d92bece46 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -1,17 +1,31 @@ -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; -import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; import { SearchService } from '../../core/shared/search/search.service'; -import { ViewModeSwitchComponent } from './view-mode-switch.component'; -import { SearchServiceStub } from '../testing/search-service.stub'; import { ViewMode } from '../../core/shared/view-mode.model'; -import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; +import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; +import { SearchServiceStub } from '../testing/search-service.stub'; +import { ViewModeSwitchComponent } from './view-mode-switch.component'; -@Component({ template: '' }) +@Component({ + template: '', + standalone: true, +}) class DummyComponent { } @@ -28,23 +42,20 @@ describe('ViewModeSwitchComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), RouterTestingModule.withRoutes([ { path: 'search', component: DummyComponent, pathMatch: 'full' }, - ]) - ], - declarations: [ + ]), ViewModeSwitchComponent, DummyComponent, - BrowserOnlyMockPipe, ], providers: [ { provide: SearchService, useValue: searchService }, ], }).overrideComponent(ViewModeSwitchComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } + set: { changeDetection: ChangeDetectionStrategy.Default }, }).compileComponents(); })); diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.ts index 95f35abf175..baed3c93363 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.ts @@ -1,13 +1,29 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; - +import { NgIf } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { + Router, + RouterLink, + RouterLinkActive, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; import { SearchService } from '../../core/shared/search/search.service'; import { ViewMode } from '../../core/shared/view-mode.model'; -import { isEmpty, isNotEmpty } from '../empty.util'; +import { + isEmpty, + isNotEmpty, +} from '../empty.util'; +import { BrowserOnlyPipe } from '../utils/browser-only.pipe'; import { currentPath } from '../utils/route.utils'; -import { Router } from '@angular/router'; -import { filter } from 'rxjs/operators'; /** * Component to switch between list and grid views. @@ -15,7 +31,9 @@ import { filter } from 'rxjs/operators'; @Component({ selector: 'ds-view-mode-switch', styleUrls: ['./view-mode-switch.component.scss'], - templateUrl: './view-mode-switch.component.html' + templateUrl: './view-mode-switch.component.html', + standalone: true, + imports: [NgIf, RouterLink, RouterLinkActive, TranslateModule, BrowserOnlyPipe], }) export class ViewModeSwitchComponent implements OnInit, OnDestroy { @@ -62,7 +80,7 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { } this.sub = this.searchService.getViewMode().pipe( - filter((viewMode: ViewMode) => isNotEmpty(viewMode)) + filter((viewMode: ViewMode) => isNotEmpty(viewMode)), ).subscribe((viewMode: ViewMode) => { this.currentMode = viewMode; }); diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts index d5bb80dfde9..c78fef0521e 100644 --- a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts +++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts @@ -1,20 +1,27 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { CollectionStatisticsPageComponent } from './collection-statistics-page.component'; -import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { of as observableOf } from 'rxjs'; -import { Collection } from '../../core/shared/collection.model'; +import { CommonModule } from '@angular/common'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { UsageReport } from '../../core/statistics/models/usage-report.model'; -import { SharedModule } from '../../shared/shared.module'; -import { CommonModule } from '@angular/common'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { Collection } from '../../core/shared/collection.model'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { CollectionStatisticsPageComponent } from './collection-statistics-page.component'; describe('CollectionStatisticsPageComponent', () => { @@ -29,9 +36,9 @@ describe('CollectionStatisticsPageComponent', () => { scope: createSuccessfulRemoteDataObject( Object.assign(new Collection(), { id: 'collection_id', - }) - ) - }) + }), + ), + }), }; const router = { @@ -47,9 +54,9 @@ describe('CollectionStatisticsPageComponent', () => { new UsageReport(), { id: `${scope}-${type}-report`, points: [], - } - ) - ) + }, + ), + ), ); const nameService = { @@ -58,16 +65,13 @@ describe('CollectionStatisticsPageComponent', () => { const authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), - setRedirectUrl: {} + setRedirectUrl: {}, }); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), CommonModule, - SharedModule, - ], - declarations: [ CollectionStatisticsPageComponent, StatisticsTableComponent, ], diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts index 4875ce39cd4..e07a506b7cf 100644 --- a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts +++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts @@ -1,20 +1,24 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { ActivatedRoute , Router} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Collection } from '../../core/shared/collection.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { StatisticsPageDirective } from '../statistics-page/statistics-page.directive'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; /** * Component representing the statistics page for a collection. */ @Component({ - selector: 'ds-collection-statistics-page', + selector: 'ds-base-collection-statistics-page', templateUrl: '../statistics-page/statistics-page.component.html', - styleUrls: ['./collection-statistics-page.component.scss'] + styleUrls: ['./collection-statistics-page.component.scss'], + standalone: true, + imports: [CommonModule, VarDirective, ThemedLoadingComponent, StatisticsTableComponent, TranslateModule], }) -export class CollectionStatisticsPageComponent extends StatisticsPageComponent { +export class CollectionStatisticsPageComponent extends StatisticsPageDirective { /** * The report types to show on this statistics page. @@ -25,20 +29,4 @@ export class CollectionStatisticsPageComponent extends StatisticsPageComponent { protected getComponentName(): string { diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts index 2e63f83b8f1..e29e37880f4 100644 --- a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts +++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts @@ -1,20 +1,27 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { CommunityStatisticsPageComponent } from './community-statistics-page.component'; -import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { of as observableOf } from 'rxjs'; -import { Community } from '../../core/shared/community.model'; +import { CommonModule } from '@angular/common'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { UsageReport } from '../../core/statistics/models/usage-report.model'; -import { SharedModule } from '../../shared/shared.module'; -import { CommonModule } from '@angular/common'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { Community } from '../../core/shared/community.model'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { CommunityStatisticsPageComponent } from './community-statistics-page.component'; describe('CommunityStatisticsPageComponent', () => { @@ -29,9 +36,9 @@ describe('CommunityStatisticsPageComponent', () => { scope: createSuccessfulRemoteDataObject( Object.assign(new Community(), { id: 'community_id', - }) - ) - }) + }), + ), + }), }; const router = { @@ -47,9 +54,9 @@ describe('CommunityStatisticsPageComponent', () => { new UsageReport(), { id: `${scope}-${type}-report`, points: [], - } - ) - ) + }, + ), + ), ); const nameService = { @@ -58,16 +65,13 @@ describe('CommunityStatisticsPageComponent', () => { const authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), - setRedirectUrl: {} + setRedirectUrl: {}, }); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), CommonModule, - SharedModule, - ], - declarations: [ CommunityStatisticsPageComponent, StatisticsTableComponent, ], diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts index de7a8bbf8cc..72a2c46b9e9 100644 --- a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts +++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts @@ -1,20 +1,24 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Community } from '../../core/shared/community.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { StatisticsPageDirective } from '../statistics-page/statistics-page.directive'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; /** * Component representing the statistics page for a community. */ @Component({ - selector: 'ds-community-statistics-page', + selector: 'ds-base-community-statistics-page', templateUrl: '../statistics-page/statistics-page.component.html', - styleUrls: ['./community-statistics-page.component.scss'] + styleUrls: ['./community-statistics-page.component.scss'], + standalone: true, + imports: [CommonModule, VarDirective, ThemedLoadingComponent, StatisticsTableComponent, TranslateModule], }) -export class CommunityStatisticsPageComponent extends StatisticsPageComponent { +export class CommunityStatisticsPageComponent extends StatisticsPageDirective { /** * The report types to show on this statistics page. @@ -25,20 +29,4 @@ export class CommunityStatisticsPageComponent extends StatisticsPageComponent { protected getComponentName(): string { diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts index 88bbca3fba4..f5f3361cce1 100644 --- a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts +++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts @@ -1,20 +1,27 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ItemStatisticsPageComponent } from './item-statistics-page.component'; -import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { of as observableOf } from 'rxjs'; -import { Item } from '../../core/shared/item.model'; +import { CommonModule } from '@angular/common'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { UsageReport } from '../../core/statistics/models/usage-report.model'; -import { SharedModule } from '../../shared/shared.module'; -import { CommonModule } from '@angular/common'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { Item } from '../../core/shared/item.model'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { ItemStatisticsPageComponent } from './item-statistics-page.component'; describe('ItemStatisticsPageComponent', () => { @@ -29,9 +36,9 @@ describe('ItemStatisticsPageComponent', () => { scope: createSuccessfulRemoteDataObject( Object.assign(new Item(), { id: 'item_id', - }) - ) - }) + }), + ), + }), }; const router = { @@ -47,9 +54,9 @@ describe('ItemStatisticsPageComponent', () => { new UsageReport(), { id: `${scope}-${type}-report`, points: [], - } - ) - ) + }, + ), + ), ); const nameService = { @@ -58,16 +65,13 @@ describe('ItemStatisticsPageComponent', () => { const authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), - setRedirectUrl: {} + setRedirectUrl: {}, }); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), CommonModule, - SharedModule, - ], - declarations: [ ItemStatisticsPageComponent, StatisticsTableComponent, ], diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts index f5f107af747..702e39806f7 100644 --- a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts +++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts @@ -1,20 +1,24 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Item } from '../../core/shared/item.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { StatisticsPageDirective } from '../statistics-page/statistics-page.directive'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; /** * Component representing the statistics page for an item. */ @Component({ - selector: 'ds-item-statistics-page', + selector: 'ds-base-item-statistics-page', templateUrl: '../statistics-page/statistics-page.component.html', - styleUrls: ['./item-statistics-page.component.scss'] + styleUrls: ['./item-statistics-page.component.scss'], + standalone: true, + imports: [CommonModule, VarDirective, ThemedLoadingComponent, StatisticsTableComponent, TranslateModule], }) -export class ItemStatisticsPageComponent extends StatisticsPageComponent { +export class ItemStatisticsPageComponent extends StatisticsPageDirective { /** * The report types to show on this statistics page. @@ -26,20 +30,4 @@ export class ItemStatisticsPageComponent extends StatisticsPageComponent { 'TopCountries', 'TopCities', ]; - - constructor( - protected route: ActivatedRoute, - protected router: Router, - protected usageReportService: UsageReportDataService, - protected nameService: DSONameService, - protected authService: AuthService - ) { - super( - route, - router, - usageReportService, - nameService, - authService, - ); - } } diff --git a/src/app/statistics-page/item-statistics-page/themed-item-statistics-page.component.ts b/src/app/statistics-page/item-statistics-page/themed-item-statistics-page.component.ts index 50e26329a97..88d4c04d151 100644 --- a/src/app/statistics-page/item-statistics-page/themed-item-statistics-page.component.ts +++ b/src/app/statistics-page/item-statistics-page/themed-item-statistics-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { ItemStatisticsPageComponent } from './item-statistics-page.component'; @@ -6,9 +7,11 @@ import { ItemStatisticsPageComponent } from './item-statistics-page.component'; * Themed wrapper for ItemStatisticsPageComponent */ @Component({ - selector: 'ds-themed-item-statistics-page', + selector: 'ds-item-statistics-page', styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html', + standalone: true, + imports: [ItemStatisticsPageComponent], }) export class ThemedItemStatisticsPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts index 3c181c18161..9b23afdd74c 100644 --- a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts +++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts @@ -1,20 +1,27 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { SiteStatisticsPageComponent } from './site-statistics-page.component'; -import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { of as observableOf } from 'rxjs'; -import { Site } from '../../core/shared/site.model'; +import { CommonModule } from '@angular/common'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { UsageReport } from '../../core/statistics/models/usage-report.model'; -import { SharedModule } from '../../shared/shared.module'; -import { CommonModule } from '@angular/common'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { SiteDataService } from '../../core/data/site-data.service'; -import { AuthService } from '../../core/auth/auth.service'; +import { Site } from '../../core/shared/site.model'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { SiteStatisticsPageComponent } from './site-statistics-page.component'; describe('SiteStatisticsPageComponent', () => { @@ -36,7 +43,7 @@ describe('SiteStatisticsPageComponent', () => { new UsageReport(), { id: `site_id-TotalVisits-report`, points: [], - } + }, ), ]), }; @@ -53,21 +60,18 @@ describe('SiteStatisticsPageComponent', () => { href: 'test_site_link', }, }, - })) + })), }; const authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), - setRedirectUrl: {} + setRedirectUrl: {}, }); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), CommonModule, - SharedModule, - ], - declarations: [ SiteStatisticsPageComponent, StatisticsTableComponent, ], diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts index 5eb19bec563..eaed9f5401d 100644 --- a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts +++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts @@ -1,22 +1,26 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { switchMap } from 'rxjs/operators'; + import { SiteDataService } from '../../core/data/site-data.service'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; import { Site } from '../../core/shared/site.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { switchMap } from 'rxjs/operators'; -import { AuthService } from '../../core/auth/auth.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { StatisticsPageDirective } from '../statistics-page/statistics-page.directive'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; /** * Component representing the site-wide statistics page. */ @Component({ - selector: 'ds-site-statistics-page', + selector: 'ds-base-site-statistics-page', templateUrl: '../statistics-page/statistics-page.component.html', - styleUrls: ['./site-statistics-page.component.scss'] + styleUrls: ['./site-statistics-page.component.scss'], + standalone: true, + imports: [CommonModule, VarDirective, ThemedLoadingComponent, StatisticsTableComponent, TranslateModule], }) -export class SiteStatisticsPageComponent extends StatisticsPageComponent { +export class SiteStatisticsPageComponent extends StatisticsPageDirective { /** * The report types to show on this statistics page. @@ -25,21 +29,8 @@ export class SiteStatisticsPageComponent extends StatisticsPageComponent { 'TotalVisits', ]; - constructor( - protected route: ActivatedRoute, - protected router: Router, - protected usageReportService: UsageReportDataService, - protected nameService: DSONameService, - protected siteService: SiteDataService, - protected authService: AuthService, - ) { - super( - route, - router, - usageReportService, - nameService, - authService, - ); + constructor(protected siteService: SiteDataService) { + super(); } protected getScope$() { diff --git a/src/app/statistics-page/site-statistics-page/themed-site-statistics-page.component.ts b/src/app/statistics-page/site-statistics-page/themed-site-statistics-page.component.ts index 3f841163ed2..3792b33c593 100644 --- a/src/app/statistics-page/site-statistics-page/themed-site-statistics-page.component.ts +++ b/src/app/statistics-page/site-statistics-page/themed-site-statistics-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { SiteStatisticsPageComponent } from './site-statistics-page.component'; @@ -6,9 +7,11 @@ import { SiteStatisticsPageComponent } from './site-statistics-page.component'; * Themed wrapper for SiteStatisticsPageComponent */ @Component({ - selector: 'ds-themed-site-statistics-page', + selector: 'ds-site-statistics-page', styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html', + standalone: true, + imports: [SiteStatisticsPageComponent], }) export class ThemedSiteStatisticsPageComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/statistics-page/statistics-page-routes.ts b/src/app/statistics-page/statistics-page-routes.ts new file mode 100644 index 00000000000..69bcc6b41c1 --- /dev/null +++ b/src/app/statistics-page/statistics-page-routes.ts @@ -0,0 +1,70 @@ +import { Route } from '@angular/router'; + +import { collectionPageResolver } from '../collection-page/collection-page.resolver'; +import { communityPageResolver } from '../community-page/community-page.resolver'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { statisticsAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard'; +import { itemResolver } from '../item-page/item.resolver'; +import { ThemedCollectionStatisticsPageComponent } from './collection-statistics-page/themed-collection-statistics-page.component'; +import { ThemedCommunityStatisticsPageComponent } from './community-statistics-page/themed-community-statistics-page.component'; +import { ThemedItemStatisticsPageComponent } from './item-statistics-page/themed-item-statistics-page.component'; +import { ThemedSiteStatisticsPageComponent } from './site-statistics-page/themed-site-statistics-page.component'; + +export const ROUTES: Route[] = [ + { + path: '', + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics', + }, + children: [ + { + path: '', + component: ThemedSiteStatisticsPageComponent, + }, + ], + canActivate: [statisticsAdministratorGuard], + }, + { + path: `items/:id`, + resolve: { + scope: itemResolver, + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics', + }, + component: ThemedItemStatisticsPageComponent, + canActivate: [statisticsAdministratorGuard], + }, + { + path: `collections/:id`, + resolve: { + scope: collectionPageResolver, + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics', + }, + component: ThemedCollectionStatisticsPageComponent, + canActivate: [statisticsAdministratorGuard], + }, + { + path: `communities/:id`, + resolve: { + scope: communityPageResolver, + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics', + }, + component: ThemedCommunityStatisticsPageComponent, + canActivate: [statisticsAdministratorGuard], + }, +]; diff --git a/src/app/statistics-page/statistics-page-routing.module.ts b/src/app/statistics-page/statistics-page-routing.module.ts deleted file mode 100644 index ef6f68d5570..00000000000 --- a/src/app/statistics-page/statistics-page-routing.module.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; -import { StatisticsPageModule } from './statistics-page.module'; -import { CollectionPageResolver } from '../collection-page/collection-page.resolver'; -import { CommunityPageResolver } from '../community-page/community-page.resolver'; -import { ThemedCollectionStatisticsPageComponent } from './collection-statistics-page/themed-collection-statistics-page.component'; -import { ThemedCommunityStatisticsPageComponent } from './community-statistics-page/themed-community-statistics-page.component'; -import { ThemedItemStatisticsPageComponent } from './item-statistics-page/themed-item-statistics-page.component'; -import { ThemedSiteStatisticsPageComponent } from './site-statistics-page/themed-site-statistics-page.component'; -import { ItemResolver } from '../item-page/item.resolver'; -import { StatisticsAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard'; - -@NgModule({ - imports: [ - StatisticsPageModule, - RouterModule.forChild([ - { - path: '', - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { - title: 'statistics.title', - breadcrumbKey: 'statistics' - }, - children: [ - { - path: '', - component: ThemedSiteStatisticsPageComponent, - }, - ], - canActivate: [StatisticsAdministratorGuard] - }, - { - path: `items/:id`, - resolve: { - scope: ItemResolver, - breadcrumb: I18nBreadcrumbResolver - }, - data: { - title: 'statistics.title', - breadcrumbKey: 'statistics' - }, - component: ThemedItemStatisticsPageComponent, - canActivate: [StatisticsAdministratorGuard] - }, - { - path: `collections/:id`, - resolve: { - scope: CollectionPageResolver, - breadcrumb: I18nBreadcrumbResolver - }, - data: { - title: 'statistics.title', - breadcrumbKey: 'statistics' - }, - component: ThemedCollectionStatisticsPageComponent, - canActivate: [StatisticsAdministratorGuard] - }, - { - path: `communities/:id`, - resolve: { - scope: CommunityPageResolver, - breadcrumb: I18nBreadcrumbResolver - }, - data: { - title: 'statistics.title', - breadcrumbKey: 'statistics' - }, - component: ThemedCommunityStatisticsPageComponent, - canActivate: [StatisticsAdministratorGuard] - }, - ] - ) - ], - providers: [ - I18nBreadcrumbResolver, - I18nBreadcrumbsService, - CollectionPageResolver, - CommunityPageResolver, - ItemResolver - ] -}) -export class StatisticsPageRoutingModule { -} diff --git a/src/app/statistics-page/statistics-page.module.ts b/src/app/statistics-page/statistics-page.module.ts deleted file mode 100644 index 75726de94cc..00000000000 --- a/src/app/statistics-page/statistics-page.module.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { CoreModule } from '../core/core.module'; -import { SharedModule } from '../shared/shared.module'; -import { StatisticsModule } from '../statistics/statistics.module'; -import { UsageReportDataService } from '../core/statistics/usage-report-data.service'; -import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component'; -import { StatisticsTableComponent } from './statistics-table/statistics-table.component'; -import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component'; -import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component'; -import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component'; -import { ThemedCollectionStatisticsPageComponent } from './collection-statistics-page/themed-collection-statistics-page.component'; -import { ThemedCommunityStatisticsPageComponent } from './community-statistics-page/themed-community-statistics-page.component'; -import { ThemedItemStatisticsPageComponent } from './item-statistics-page/themed-item-statistics-page.component'; -import { ThemedSiteStatisticsPageComponent } from './site-statistics-page/themed-site-statistics-page.component'; - -const components = [ - StatisticsTableComponent, - SiteStatisticsPageComponent, - ItemStatisticsPageComponent, - CollectionStatisticsPageComponent, - CommunityStatisticsPageComponent, - ThemedCollectionStatisticsPageComponent, - ThemedCommunityStatisticsPageComponent, - ThemedItemStatisticsPageComponent, - ThemedSiteStatisticsPageComponent -]; - -@NgModule({ - imports: [ - CommonModule, - SharedModule, - CoreModule.forRoot(), - StatisticsModule.forRoot() - ], - declarations: components, - providers: [ - UsageReportDataService, - ], - exports: components -}) - -/** - * This module handles all components and pipes that are necessary for the search page - */ -export class StatisticsPageModule { -} diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.html b/src/app/statistics-page/statistics-page/statistics-page.component.html index e99f3f01985..78e736bd3ae 100644 --- a/src/app/statistics-page/statistics-page/statistics-page.component.html +++ b/src/app/statistics-page/statistics-page/statistics-page.component.html @@ -2,7 +2,6 @@

{{ 'statistics.header' | translate: { scope: getName(scope) } }} @@ -12,7 +11,7 @@ - + @@ -20,7 +19,7 @@ [report]="report" class="m-2 {{ report.id }}"> -
+
{{ 'statistics.page.no-data' | translate }}
diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.ts b/src/app/statistics-page/statistics-page/statistics-page.directive.ts similarity index 75% rename from src/app/statistics-page/statistics-page/statistics-page.component.ts rename to src/app/statistics-page/statistics-page/statistics-page.directive.ts index 8b91d54ae8f..e781b89487e 100644 --- a/src/app/statistics-page/statistics-page/statistics-page.component.ts +++ b/src/app/statistics-page/statistics-page/statistics-page.directive.ts @@ -1,27 +1,38 @@ -import { Component, OnInit } from '@angular/core'; -import { combineLatest, Observable } from 'rxjs'; -import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; -import { map, switchMap } from 'rxjs/operators'; -import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { + Directive, + inject, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { AuthService } from '../../core/auth/auth.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { RemoteData } from '../../core/data/remote-data'; +import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { + getFirstSucceededRemoteData, getRemoteDataPayload, - getFirstSucceededRemoteData } from '../../core/shared/operators'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { ActivatedRoute, Router } from '@angular/router'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; +@Directive() /** * Class representing an abstract statistics page component. */ -@Component({ - selector: 'ds-statistics-page', - template: '' -}) -export abstract class StatisticsPageComponent implements OnInit { +export abstract class StatisticsPageDirective implements OnInit { /** * The scope dso for this statistics page, as an Observable. @@ -40,21 +51,18 @@ export abstract class StatisticsPageComponent implements hasData$: Observable; - constructor( - protected route: ActivatedRoute, - protected router: Router, - protected usageReportService: UsageReportDataService, - protected nameService: DSONameService, - protected authService: AuthService, - ) { - } + protected route = inject(ActivatedRoute); + protected router = inject(Router); + protected usageReportService = inject(UsageReportDataService); + protected nameService = inject(DSONameService); + protected authService = inject(AuthService); ngOnInit(): void { this.scope$ = this.getScope$(); this.reports$ = this.getReports$(); this.hasData$ = this.reports$.pipe( map((reports) => reports.some( - (report) => report.points.length > 0 + (report) => report.points.length > 0, )), ); } @@ -78,7 +86,7 @@ export abstract class StatisticsPageComponent implements return this.scope$.pipe( switchMap((scope) => combineLatest( - this.types.map((type) => this.usageReportService.getStatistic(scope.id, type)) + this.types.map((type) => this.usageReportService.getStatistic(scope.id, type)), ), ), ); diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.html b/src/app/statistics-page/statistics-table/statistics-table.component.html index 3b59e6ca95b..efa9ce43d99 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.html +++ b/src/app/statistics-page/statistics-table/statistics-table.component.html @@ -5,32 +5,32 @@

{{ 'statistics.table.title.' + report.reportType | translate }}

-
-
-
-
-
-
- {{ header }} -
-
-
-
- {{ getLabel(point) | async }} -
-
- {{ point.values[header] }} -
-
-
-
+

{{messagePrefix + '.table.id' | translate}}
{{ePerson.eperson.id}}
{{eperson.id}} - - {{ dsoNameService.getName(ePerson.eperson) }} + + {{ dsoNameService.getName(eperson) }} - {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
- {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} + {{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
- -
@@ -156,9 +136,10 @@

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

- 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 7c8db399bcd..9a6b2b4f053 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 @@ -1,35 +1,74 @@ import { CommonModule } from '@angular/common'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { Router } from '@angular/router'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Observable, of as observableOf } from 'rxjs'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { RestResponse } from '../../../../core/cache/response.models'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { EPerson } from '../../../../core/eperson/models/eperson.model'; import { Group } from '../../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; +import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock'; +import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../../../shared/mocks/router.mock'; +import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; -import { MembersListComponent } from './members-list.component'; -import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; -import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; -import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub'; +import { + EPersonMock, + EPersonMock2, +} from '../../../../shared/testing/eperson.mock'; +import { GroupMock } from '../../../../shared/testing/group-mock'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { RouterMock } from '../../../../shared/mocks/router.mock'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { MembersListComponent } from './members-list.component'; + +// todo: optimize imports describe('MembersListComponent', () => { let component: MembersListComponent; @@ -39,28 +78,26 @@ describe('MembersListComponent', () => { let ePersonDataServiceStub: any; let groupsDataServiceStub: any; let activeGroup; - let allEPersons: EPerson[]; - let allGroups: Group[]; let epersonMembers: EPerson[]; - let subgroupMembers: Group[]; + let epersonNonMembers: EPerson[]; let paginationService; beforeEach(waitForAsync(() => { activeGroup = GroupMock; epersonMembers = [EPersonMock2]; - subgroupMembers = [GroupMock2]; - allEPersons = [EPersonMock, EPersonMock2]; - allGroups = [GroupMock, GroupMock2]; + epersonNonMembers = [EPersonMock]; ePersonDataServiceStub = { activeGroup: activeGroup, epersonMembers: epersonMembers, - subgroupMembers: subgroupMembers, + epersonNonMembers: epersonNonMembers, + // This method is used to get all the current members findListByHref(_href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, - searchByScope(scope: string, query: string): Observable>> { + // This method is used to search across *non-members* + searchNonMembers(query: string, group: string): Observable>> { if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allEPersons)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), epersonNonMembers)); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); }, @@ -70,29 +107,26 @@ describe('MembersListComponent', () => { clearLinkRequests() { // empty }, - getEPeoplePageRouterLink(): string { - return '/access-control/epeople'; - } }; groupsDataServiceStub = { activeGroup: activeGroup, epersonMembers: epersonMembers, - subgroupMembers: subgroupMembers, - allGroups: allGroups, + epersonNonMembers: epersonNonMembers, getActiveGroup(): Observable { return observableOf(activeGroup); }, getEPersonMembers() { return this.epersonMembers; }, - searchGroups(query: string): Observable>> { - if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.allGroups)); - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); - }, - addMemberToGroup(parentGroup, eperson: EPerson): Observable { - this.epersonMembers = [...this.epersonMembers, eperson]; + addMemberToGroup(parentGroup, epersonToAdd: EPerson): Observable { + // Add eperson to list of members + this.epersonMembers = [...this.epersonMembers, epersonToAdd]; + // Remove eperson from list of non-members + this.epersonNonMembers.forEach( (eperson: EPerson, index: number) => { + if (eperson.id === epersonToAdd.id) { + this.epersonNonMembers.splice(index, 1); + } + }); return observableOf(new RestResponse(true, 200, 'Success')); }, clearGroupsRequests() { @@ -105,16 +139,16 @@ describe('MembersListComponent', () => { return '/access-control/groups/' + group.id; }, deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable { - this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => { - if (eperson.id !== epersonToDelete.id) { - return eperson; + // Remove eperson from list of members + this.epersonMembers.forEach( (eperson: EPerson, index: number) => { + if (eperson.id === epersonToDelete.id) { + this.epersonMembers.splice(index, 1); } }); - if (this.epersonMembers === undefined) { - this.epersonMembers = []; - } + // Add eperson to list of non-members + this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete]; return observableOf(new RestResponse(true, 200, 'Success')); - } + }, }; builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); @@ -125,11 +159,9 @@ describe('MembersListComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), - ], - declarations: [MembersListComponent], + useClass: TranslateLoaderMock, + }, + }), MembersListComponent], providers: [MembersListComponent, { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: GroupDataService, useValue: groupsDataServiceStub }, @@ -138,9 +170,16 @@ describe('MembersListComponent', () => { { provide: Router, useValue: new RouterMock() }, { provide: PaginationService, useValue: paginationService }, { provide: DSONameService, useValue: new DSONameServiceMock() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(MembersListComponent, { + remove: { + imports: [PaginationComponent, ContextHelpDirective], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -160,13 +199,37 @@ describe('MembersListComponent', () => { expect(comp).toBeDefined(); })); - it('should show list of eperson members of current active group', () => { - const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child')); - expect(epersonIdsFound.length).toEqual(1); - epersonMembers.map((eperson: EPerson) => { - expect(epersonIdsFound.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === eperson.uuid); - })).toBeTruthy(); + describe('current members list', () => { + it('should show list of eperson members of current active group', () => { + const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child')); + expect(epersonIdsFound.length).toEqual(1); + epersonMembers.map((eperson: EPerson) => { + expect(epersonIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === eperson.uuid); + })).toBeTruthy(); + }); + }); + + it('should show a delete button next to each member', () => { + const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr')); + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).toBeNull(); + expect(deleteButton).not.toBeNull(); + }); + }); + + describe('if first delete button is pressed', () => { + beforeEach(() => { + spyOn(component, 'search').and.callThrough(); + const deleteButton: DebugElement = fixture.debugElement.query(By.css('#ePeopleMembersOfGroup tbody .fa-trash-alt')); + deleteButton.nativeElement.click(); + fixture.detectChanges(); + }); + it('should trigger the search to add the user back to the search table', () => { + expect(component.search).toHaveBeenCalled(); + }); }); }); @@ -174,76 +237,40 @@ describe('MembersListComponent', () => { describe('when searching without query', () => { let epersonsFound: DebugElement[]; beforeEach(fakeAsync(() => { - spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => { - return observableOf(activeGroup.epersons.includes(ePerson)); - }); component.search({ scope: 'metadata', query: '' }); tick(); fixture.detectChanges(); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - // Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything - // because they don't change the value of activeGroup.epersons) - jasmine.getEnv().allowRespy(true); - spyOn(component, 'isMemberOfGroup').and.callThrough(); })); - it('should display all epersons', () => { - expect(epersonsFound.length).toEqual(2); + it('should display only non-members of the group', () => { + const epersonIdsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr td:first-child')); + expect(epersonIdsFound.length).toEqual(1); + epersonNonMembers.map((eperson: EPerson) => { + expect(epersonIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === eperson.uuid); + })).toBeTruthy(); + }); }); - describe('if eperson is already a eperson', () => { - it('should have delete button, else it should have add button', () => { - const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id); - epersonsFound.map((foundEPersonRowElement: DebugElement) => { - const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child')); - const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); - if (memberIds.includes(epersonId.nativeElement.textContent)) { - expect(addButton).toBeNull(); - expect(deleteButton).not.toBeNull(); - } else { - expect(deleteButton).toBeNull(); - expect(addButton).not.toBeNull(); - } - }); + it('should display an add button next to non-members, not a delete button', () => { + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).not.toBeNull(); + expect(deleteButton).toBeNull(); }); }); describe('if first add button is pressed', () => { - beforeEach(fakeAsync(() => { + beforeEach(() => { + spyOn(component, 'search').and.callThrough(); const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); addButton.nativeElement.click(); - tick(); fixture.detectChanges(); - })); - it('then all the ePersons are member of the active group', () => { - epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - expect(epersonsFound.length).toEqual(2); - epersonsFound.map((foundEPersonRowElement: DebugElement) => { - const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); - expect(addButton).toBeNull(); - expect(deleteButton).not.toBeNull(); - }); }); - }); - - describe('if first delete button is pressed', () => { - beforeEach(fakeAsync(() => { - const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt')); - deleteButton.nativeElement.click(); - tick(); - fixture.detectChanges(); - })); - it('then no ePerson is member of the active group', () => { - epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - expect(epersonsFound.length).toEqual(2); - epersonsFound.map((foundEPersonRowElement: DebugElement) => { - const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); - expect(deleteButton).toBeNull(); - expect(addButton).not.toBeNull(); - }); + it('should trigger the search to remove the user from the search table', () => { + expect(component.search).toHaveBeenCalled(); }); }); }); 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 b3e686c0123..22934394c8a 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 @@ -1,41 +1,75 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormBuilder } from '@angular/forms'; -import { Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; import { - Observable, - of as observableOf, - Subscription, + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + ReactiveFormsModule, + UntypedFormBuilder, +} from '@angular/forms'; +import { + Router, + RouterLink, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest as observableCombineLatest, + Observable, ObservedValueOf, + of as observableOf, + Subscription, } from 'rxjs'; -import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { + defaultIfEmpty, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model'; import { Group } from '../../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { - getFirstSucceededRemoteData, - getFirstCompletedRemoteData, getAllCompletedRemoteData, - getRemoteDataPayload + getFirstCompletedRemoteData, + getRemoteDataPayload, } from '../../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; +import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { getEPersonEditRoute } from '../../../access-control-routing-paths'; + +// todo: optimize imports /** * Keys to keep track of specific subscriptions */ enum SubKey { ActiveGroup, - MembersDTO, - SearchResultsDTO, + Members, + SearchResults, } /** @@ -69,7 +103,20 @@ export interface EPersonListActionConfig { @Component({ selector: 'ds-members-list', - templateUrl: './members-list.component.html' + templateUrl: './members-list.component.html', + imports: [ + TranslateModule, + ContextHelpDirective, + ReactiveFormsModule, + PaginationComponent, + NgIf, + AsyncPipe, + RouterLink, + NgClass, + NgForOf, + BtnDisabledDirective, + ], + standalone: true, }) /** * The list of members in the edit group page @@ -89,18 +136,18 @@ export class MembersListComponent implements OnInit, OnDestroy { remove: { css: 'btn-outline-danger', disabled: false, - icon: 'fas fa-trash-alt fa-fw' + icon: 'fas fa-trash-alt fa-fw', }, }; /** * EPeople being displayed in search result, initially all members, after search result of search */ - ePeopleSearchDtos: BehaviorSubject> = new BehaviorSubject>(undefined); + ePeopleSearch: BehaviorSubject> = new BehaviorSubject>(undefined); /** * List of EPeople members of currently active group being edited */ - ePeopleMembersOfGroupDtos: BehaviorSubject> = new BehaviorSubject>(undefined); + ePeopleMembersOfGroup: BehaviorSubject> = new BehaviorSubject(undefined); /** * Pagination config used to display the list of EPeople that are result of EPeople search @@ -108,7 +155,7 @@ export class MembersListComponent implements OnInit, OnDestroy { configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'sml', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** * Pagination config used to display the list of EPerson Membes of active group being edited @@ -116,7 +163,7 @@ export class MembersListComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'ml', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** @@ -129,7 +176,6 @@ export class MembersListComponent implements OnInit, OnDestroy { // Current search in edit group - epeople search form currentSearchQuery: string; - currentSearchScope: string; // Whether or not user has done a EPeople search yet searchDone: boolean; @@ -137,6 +183,8 @@ export class MembersListComponent implements OnInit, OnDestroy { // current active group being edited groupBeingEdited: Group; + readonly getEPersonEditRoute = getEPersonEditRoute; + constructor( protected groupDataService: GroupDataService, public ePersonDataService: EPersonDataService, @@ -148,18 +196,17 @@ export class MembersListComponent implements OnInit, OnDestroy { public dsoNameService: DSONameService, ) { this.currentSearchQuery = ''; - this.currentSearchScope = 'metadata'; } ngOnInit(): void { this.searchForm = this.formBuilder.group(({ - scope: 'metadata', query: '', })); this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => { if (activeGroup != null) { this.groupBeingEdited = activeGroup; this.retrieveMembers(this.config.currentPage); + this.search({ query: '' }); } })); } @@ -171,14 +218,14 @@ export class MembersListComponent implements OnInit, OnDestroy { * @private */ retrieveMembers(page: number): void { - this.unsubFrom(SubKey.MembersDTO); - this.subs.set(SubKey.MembersDTO, + this.unsubFrom(SubKey.Members); + this.subs.set(SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((currentPagination) => { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { - currentPage: currentPagination.currentPage, - elementsPerPage: currentPagination.pageSize - } + currentPage: currentPagination.currentPage, + elementsPerPage: currentPagination.pageSize, + }, ); }), getAllCompletedRemoteData(), @@ -195,7 +242,7 @@ export class MembersListComponent implements OnInit, OnDestroy { this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); epersonDtoModel.eperson = member; - epersonDtoModel.memberOfGroup = isMember; + epersonDtoModel.ableToDelete = isMember; return epersonDtoModel; }); return dto$; @@ -203,33 +250,21 @@ export class MembersListComponent implements OnInit, OnDestroy { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); - })) - .subscribe((paginatedListOfDTOs: PaginatedList) => { - this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs); - })); + }), + ).subscribe((paginatedListOfDTOs: PaginatedList) => { + this.ePeopleMembersOfGroup.next(paginatedListOfDTOs); + }), + ); } /** - * Whether the given ePerson is a member of the group currently being edited + * We always return true since this is only used by the top section (which represents all the users part of the group + * in {@link MembersListComponent}) + * * @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited */ isMemberOfGroup(possibleMember: EPerson): Observable { - return this.groupDataService.getActiveGroup().pipe(take(1), - mergeMap((group: Group) => { - if (group != null) { - return this.ePersonDataService.findListByHref(group._links.epersons.href, { - currentPage: 1, - elementsPerPage: 9999 - }) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((listEPeopleInGroup: PaginatedList) => listEPeopleInGroup.page.filter((ePersonInList: EPerson) => ePersonInList.id === possibleMember.id)), - map((epeople: EPerson[]) => epeople.length > 0)); - } else { - return observableOf(false); - } - })); + return observableOf(true); } /** @@ -248,14 +283,18 @@ export class MembersListComponent implements OnInit, OnDestroy { /** * Deletes a given EPerson from the members list of the group currently being edited - * @param ePerson EPerson we want to delete as member from group that is currently being edited + * @param eperson EPerson we want to delete as member from group that is currently being edited */ - deleteMemberFromGroup(ePerson: EpersonDtoModel) { - ePerson.memberOfGroup = false; + deleteMemberFromGroup(eperson: EPerson) { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup != null) { - const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); - this.showNotifications('deleteMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); + const response = this.groupDataService.deleteMemberFromGroup(activeGroup, eperson); + this.showNotifications('deleteMember', response, this.dsoNameService.getName(eperson), activeGroup); + // Reload search results (if there is an active query). + // This will potentially add this deleted subgroup into the list of search results. + if (this.currentSearchQuery != null) { + this.search({ query: this.currentSearchQuery }); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -264,14 +303,18 @@ export class MembersListComponent implements OnInit, OnDestroy { /** * Adds a given EPerson to the members list of the group currently being edited - * @param ePerson EPerson we want to add as member to group that is currently being edited + * @param eperson EPerson we want to add as member to group that is currently being edited */ - addMemberToGroup(ePerson: EpersonDtoModel) { - ePerson.memberOfGroup = true; + addMemberToGroup(eperson: EPerson) { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup != null) { - const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson); - this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); + const response = this.groupDataService.addMemberToGroup(activeGroup, eperson); + this.showNotifications('addMember', response, this.dsoNameService.getName(eperson), activeGroup); + // Reload search results (if there is an active query). + // This will potentially add this deleted subgroup into the list of search results. + if (this.currentSearchQuery != null) { + this.search({ query: this.currentSearchQuery }); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -279,37 +322,25 @@ export class MembersListComponent implements OnInit, OnDestroy { } /** - * Search in the EPeople by name, email or metadata - * @param data Contains scope and query param + * Search all EPeople who are NOT a member of the current group by name, email or metadata + * @param data Contains query param */ search(data: any) { - this.unsubFrom(SubKey.SearchResultsDTO); - this.subs.set(SubKey.SearchResultsDTO, + this.unsubFrom(SubKey.SearchResults); + this.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( switchMap((paginationOptions) => { - const query: string = data.query; - const scope: string = data.scope; if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) { - this.router.navigate([], { - queryParamsHandling: 'merge' - }); this.currentSearchQuery = query; this.paginationService.resetPage(this.configSearch.id); } - if (scope != null && this.currentSearchScope !== scope && this.groupBeingEdited) { - this.router.navigate([], { - queryParamsHandling: 'merge' - }); - this.currentSearchScope = scope; - this.paginationService.resetPage(this.configSearch.id); - } this.searchDone = true; - return this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, { currentPage: paginationOptions.currentPage, - elementsPerPage: paginationOptions.pageSize - }); + elementsPerPage: paginationOptions.pageSize, + }, false, true); }), getAllCompletedRemoteData(), map((rd: RemoteData) => { @@ -319,23 +350,9 @@ export class MembersListComponent implements OnInit, OnDestroy { return rd; } }), - switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { - const dto$: Observable = observableCombineLatest( - this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { - const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); - epersonDtoModel.eperson = member; - epersonDtoModel.memberOfGroup = isMember; - return epersonDtoModel; - }); - return dto$; - })]); - return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { - return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); - })); - })) - .subscribe((paginatedListOfDTOs: PaginatedList) => { - this.ePeopleSearchDtos.next(paginatedListOfDTOs); + getRemoteDataPayload()) + .subscribe((paginatedListOfEPersons: PaginatedList) => { + this.ePeopleSearch.next(paginatedListOfEPersons); })); } diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html index d009f0283eb..66404bde0d1 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html @@ -1,6 +1,54 @@

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

+

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

+ + + +
+ + + + + + + + + + + + + + + + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{group.id}} + + {{ dsoNameService.getName(group) }} + + {{ dsoNameService.getName((group.object | async)?.payload)}} +
+ +
+
+
+
+ + +
{{ dsoNameService.getName((group.object | async)?.payload) }}
- - - {{ messagePrefix + '.table.edit.currentGroup' | translate }} - -
- -

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

- - - -
- - - - - - - - - - - - - - - - - -
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{group.id}} - - {{ dsoNameService.getName(group) }} - - {{ dsoNameService.getName((group.object | async)?.payload)}} -
- -
-
-
-
- - - 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 ac5750dcaca..5b39102ca8a 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 @@ -1,36 +1,69 @@ import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { Router } from '@angular/router'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Observable, of as observableOf, BehaviorSubject } from 'rxjs'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { RestResponse } from '../../../../core/cache/response.models'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { Group } from '../../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; -import { SubgroupsListComponent } from './subgroups-list.component'; -import { - createSuccessfulRemoteDataObject$, - createSuccessfulRemoteDataObject -} from '../../../../shared/remote-data.utils'; -import { RouterMock } from '../../../../shared/mocks/router.mock'; +import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock'; import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../../../shared/mocks/router.mock'; import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; -import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub'; +import { + GroupMock, + GroupMock2, +} from '../../../../shared/testing/group-mock'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { map } from 'rxjs/operators'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { SubgroupsListComponent } from './subgroups-list.component'; describe('SubgroupsListComponent', () => { let component: SubgroupsListComponent; @@ -39,44 +72,72 @@ describe('SubgroupsListComponent', () => { let builderService: FormBuilderService; let ePersonDataServiceStub: any; let groupsDataServiceStub: any; - let activeGroup; + let activeGroup: Group; let subgroups: Group[]; - let allGroups: Group[]; + let groupNonMembers: Group[]; let routerStub; let paginationService; + // Define a new mock activegroup for all tests below + let mockActiveGroup: Group = Object.assign(new Group(), { + handle: null, + subgroups: [GroupMock2], + epersons: [EPersonMock2], + selfRegistered: false, + permanent: false, + _links: { + self: { + href: 'https://rest.api/server/api/eperson/groups/activegroupid', + }, + subgroups: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/epersons' }, + }, + _name: 'activegroupname', + id: 'activegroupid', + uuid: 'activegroupid', + type: 'group', + }); beforeEach(waitForAsync(() => { - activeGroup = GroupMock; + activeGroup = mockActiveGroup; subgroups = [GroupMock2]; - allGroups = [GroupMock, GroupMock2]; + groupNonMembers = [GroupMock]; ePersonDataServiceStub = {}; groupsDataServiceStub = { activeGroup: activeGroup, - subgroups$: new BehaviorSubject(subgroups), + subgroups: subgroups, + groupNonMembers: groupNonMembers, getActiveGroup(): Observable { return observableOf(this.activeGroup); }, getSubgroups(): Group { - return this.activeGroup; + return this.subgroups; }, + // This method is used to get all the current subgroups findListByHref(_href: string): Observable>> { - return this.subgroups$.pipe( - map((currentGroups: Group[]) => { - return createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), currentGroups)); - }) - ); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getSubgroups())); }, getGroupEditPageRouterLink(group: Group): string { return '/access-control/groups/' + group.id; }, - searchGroups(query: string): Observable>> { + // This method is used to get all groups which are NOT currently a subgroup member + searchNonMemberGroups(query: string, group: string): Observable>> { if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allGroups)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupNonMembers)); } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); + return createSuccessfulRemoteDataObject$( + buildPaginatedList(new PageInfo(), []), + ); }, - addSubGroupToGroup(parentGroup, subgroup: Group): Observable { - this.subgroups$.next([...this.subgroups$.getValue(), subgroup]); + addSubGroupToGroup(parentGroup, subgroupToAdd: Group): Observable { + // Add group to list of subgroups + this.subgroups = [...this.subgroups, subgroupToAdd]; + // Remove group from list of non-members + this.groupNonMembers.forEach( (group: Group, index: number) => { + if (group.id === subgroupToAdd.id) { + this.groupNonMembers.splice(index, 1); + } + }); return observableOf(new RestResponse(true, 200, 'Success')); }, clearGroupsRequests() { @@ -85,40 +146,59 @@ describe('SubgroupsListComponent', () => { clearGroupLinkRequests() { // empty }, - deleteSubGroupFromGroup(parentGroup, subgroup: Group): Observable { - this.subgroups$.next(this.subgroups$.getValue().filter((group: Group) => { - if (group.id !== subgroup.id) { - return group; + deleteSubGroupFromGroup(parentGroup, subgroupToDelete: Group): Observable { + // Remove group from list of subgroups + this.subgroups.forEach( (group: Group, index: number) => { + if (group.id === subgroupToDelete.id) { + this.subgroups.splice(index, 1); } - })); + }); + // Add group to list of non-members + this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete]; return observableOf(new RestResponse(true, 200, 'Success')); - } + }, }; routerStub = new RouterMock(); builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); - paginationService = new PaginationServiceStub(); - TestBed.configureTestingModule({ - imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, + return TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbModule, + FormsModule, + ReactiveFormsModule, + BrowserModule, + // ContextHelpDirective, TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), + SubgroupsListComponent, ], - declarations: [SubgroupsListComponent], - providers: [SubgroupsListComponent, + providers: [ + SubgroupsListComponent, { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: GroupDataService, useValue: groupsDataServiceStub }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { + provide: NotificationsService, + useValue: new NotificationsServiceStub(), + }, { provide: FormBuilderService, useValue: builderService }, { provide: Router, useValue: routerStub }, { provide: PaginationService, useValue: paginationService }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(SubgroupsListComponent, { + remove: { + imports: [ContextHelpDirective, PaginationComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -137,30 +217,38 @@ describe('SubgroupsListComponent', () => { expect(comp).toBeDefined(); })); - it('should show list of subgroups of current active group', () => { - const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child')); - expect(groupIdsFound.length).toEqual(1); - activeGroup.subgroups.map((group: Group) => { - expect(groupIdsFound.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === group.uuid); - })).toBeTruthy(); + describe('current subgroup list', () => { + it('should show list of subgroups of current active group', () => { + const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child')); + expect(groupIdsFound.length).toEqual(1); + subgroups.map((group: Group) => { + expect(groupIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === group.uuid); + })).toBeTruthy(); + }); }); - }); - describe('if first group delete button is pressed', () => { - let groupsFound: DebugElement[]; - beforeEach(fakeAsync(() => { - const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); - addButton.triggerEventHandler('click', { - preventDefault: () => {/**/ - } + it('should show a delete button next to each subgroup', () => { + const subgroupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr')); + subgroupsFound.map((foundGroupRowElement: DebugElement) => { + const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).toBeNull(); + expect(deleteButton).not.toBeNull(); + }); + }); + + describe('if first group delete button is pressed', () => { + let groupsFound: DebugElement[]; + beforeEach(() => { + const deleteButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); + deleteButton.nativeElement.click(); + fixture.detectChanges(); + }); + it('then no subgroup remains as a member of the active group', () => { + groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr')); + expect(groupsFound.length).toEqual(0); }); - tick(); - fixture.detectChanges(); - })); - it('one less subgroup in list from 1 to 0 (of 2 total groups)', () => { - groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr')); - expect(groupsFound.length).toEqual(0); }); }); @@ -169,54 +257,38 @@ describe('SubgroupsListComponent', () => { let groupsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ query: '' }); + fixture.detectChanges(); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); })); - it('should display all groups', () => { - fixture.detectChanges(); - groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); - expect(groupsFound.length).toEqual(2); - groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); + it('should display only non-member groups (i.e. groups that are not a subgroup)', () => { const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child')); - allGroups.map((group: Group) => { + expect(groupIdsFound.length).toEqual(1); + groupNonMembers.map((group: Group) => { expect(groupIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === group.uuid); })).toBeTruthy(); }); }); - describe('if group is already a subgroup', () => { - it('should have delete button, else it should have add button', () => { + it('should display an add button next to non-member groups, not a delete button', () => { + groupsFound.map((foundGroupRowElement: DebugElement) => { + const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).not.toBeNull(); + expect(deleteButton).toBeNull(); + }); + }); + + describe('if first add button is pressed', () => { + beforeEach(() => { + const addButton: DebugElement = fixture.debugElement.query(By.css('#groupsSearch tbody .fa-plus')); + addButton.nativeElement.click(); fixture.detectChanges(); + }); + it('then all (two) Groups are subgroups of the active group. No non-members left', () => { groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); - const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups; - if (getSubgroups !== undefined && getSubgroups.length > 0) { - groupsFound.map((foundGroupRowElement: DebugElement) => { - const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child')); - const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); - expect(addButton).toBeNull(); - if (activeGroup.id === groupId.nativeElement.textContent) { - expect(deleteButton).toBeNull(); - } else { - expect(deleteButton).not.toBeNull(); - } - }); - } else { - const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id); - groupsFound.map((foundGroupRowElement: DebugElement) => { - const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child')); - const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); - if (subgroupIds.includes(groupId.nativeElement.textContent)) { - expect(addButton).toBeNull(); - expect(deleteButton).not.toBeNull(); - } else { - expect(deleteButton).toBeNull(); - expect(addButton).not.toBeNull(); - } - }); - } + expect(groupsFound.length).toEqual(0); }); }); }); 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 0cff730c628..95c1b7f249b 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 @@ -1,24 +1,54 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormBuilder } from '@angular/forms'; -import { Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; -import { map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + ReactiveFormsModule, + UntypedFormBuilder, +} from '@angular/forms'; +import { + Router, + RouterLink, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + Subscription, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { Group } from '../../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { NoContent } from '../../../../core/shared/NoContent.model'; import { + getAllCompletedRemoteData, getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getRemoteDataPayload } from '../../../../core/shared/operators'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { NoContent } from '../../../../core/shared/NoContent.model'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; /** * Keys to keep track of specific subscriptions @@ -31,7 +61,18 @@ enum SubKey { @Component({ selector: 'ds-subgroups-list', - templateUrl: './subgroups-list.component.html' + templateUrl: './subgroups-list.component.html', + imports: [ + RouterLink, + AsyncPipe, + NgForOf, + ContextHelpDirective, + TranslateModule, + ReactiveFormsModule, + PaginationComponent, + NgIf, + ], + standalone: true, }) /** * The list of subgroups in the edit group page @@ -50,6 +91,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { */ subGroups$: BehaviorSubject>> = new BehaviorSubject(undefined); + subGroupsPageInfoState$: Observable; + /** * Map of active subscriptions */ @@ -61,7 +104,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'ssgl', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** * Pagination config used to display the list of subgroups of currently active group being edited @@ -69,7 +112,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'sgl', pageSize: 5, - currentPage: 1 + currentPage: 1, }); // The search form @@ -103,8 +146,12 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup != null) { this.groupBeingEdited = activeGroup; this.retrieveSubGroups(); + this.search({ query: '' }); } })); + this.subGroupsPageInfoState$ = this.subGroups$.pipe( + map(subGroupsRD => subGroupsRD?.payload?.pageInfo), + ); } /** @@ -119,59 +166,18 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, { - currentPage: config.currentPage, - elementsPerPage: config.pageSize - }, - true, - true, - followLink('object') - )) + currentPage: config.currentPage, + elementsPerPage: config.pageSize, + }, + true, + true, + followLink('object'), + )), ).subscribe((rd: RemoteData>) => { this.subGroups$.next(rd); })); } - /** - * Whether or not the given group is a subgroup of the group currently being edited - * @param possibleSubgroup Group that is a possible subgroup (being tested) of the group currently being edited - */ - isSubgroupOfGroup(possibleSubgroup: Group): Observable { - return this.groupDataService.getActiveGroup().pipe(take(1), - mergeMap((activeGroup: Group) => { - if (activeGroup != null) { - if (activeGroup.uuid === possibleSubgroup.uuid) { - return observableOf(false); - } else { - return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, { - currentPage: 1, - elementsPerPage: 9999 - }) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((listTotalGroups: PaginatedList) => listTotalGroups.page.filter((groupInList: Group) => groupInList.id === possibleSubgroup.id)), - map((groups: Group[]) => groups.length > 0)); - } - } else { - return observableOf(false); - } - })); - } - - /** - * Whether or not the given group is the current group being edited - * @param group Group that is possibly the current group being edited - */ - isActiveGroup(group: Group): Observable { - return this.groupDataService.getActiveGroup().pipe(take(1), - mergeMap((activeGroup: Group) => { - if (activeGroup != null && activeGroup.uuid === group.uuid) { - return observableOf(true); - } - return observableOf(false); - })); - } - /** * Deletes given subgroup from the group currently being edited * @param subgroup Group we want to delete from the subgroups of the group currently being edited @@ -181,6 +187,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup != null) { const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup); this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); + // Reload search results (if there is an active query). + // This will potentially add this deleted subgroup into the list of search results. + if (this.currentSearchQuery != null) { + this.search({ query: this.currentSearchQuery }); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -197,6 +208,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup.uuid !== subgroup.uuid) { const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup); this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); + // Reload search results (if there is an active query). + // This will potentially remove this added subgroup from search results. + if (this.currentSearchQuery != null) { + this.search({ query: this.currentSearchQuery }); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup')); } @@ -207,28 +223,38 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { } /** - * Search in the groups (searches by group name and by uuid exact match) + * Search all non-member groups (searches by group name and by uuid exact match). Used to search for + * groups that could be added to current group as a subgroup. * @param data Contains query param */ search(data: any) { - const query: string = data.query; - if (query != null && this.currentSearchQuery !== query) { - this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited)); - this.currentSearchQuery = query; - this.configSearch.currentPage = 1; - } - this.searchDone = true; - this.unsubFrom(SubKey.SearchResults); - this.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( - switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, { - currentPage: config.currentPage, - elementsPerPage: config.pageSize - }, true, true, followLink('object') - )) - ).subscribe((rd: RemoteData>) => { - this.searchResults$.next(rd); - })); + this.subs.set(SubKey.SearchResults, + this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( + switchMap((paginationOptions) => { + const query: string = data.query; + if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) { + this.currentSearchQuery = query; + this.paginationService.resetPage(this.configSearch.id); + } + this.searchDone = true; + + return this.groupDataService.searchNonMemberGroups(this.currentSearchQuery, this.groupBeingEdited.id, { + currentPage: paginationOptions.currentPage, + elementsPerPage: paginationOptions.pageSize, + }, false, true, followLink('object')); + }), + getAllCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage })); + } else { + return rd; + } + })) + .subscribe((rd: RemoteData>) => { + this.searchResults$.next(rd); + })); } /** diff --git a/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts b/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts index 88f22413e93..056c54bf6a8 100644 --- a/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts +++ b/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts @@ -1,10 +1,13 @@ -import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { + AbstractControl, + ValidationErrors, +} from '@angular/forms'; import { Observable } from 'rxjs'; -import { map} from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; -import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; import { Group } from '../../../../core/eperson/models/group.model'; +import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; export class ValidateGroupExists { @@ -16,9 +19,9 @@ export class ValidateGroupExists { static createValidator(groupDataService: GroupDataService) { return (control: AbstractControl): Promise | Observable => { return groupDataService.searchGroups(control.value, { - currentPage: 1, - elementsPerPage: 100 - }) + currentPage: 1, + elementsPerPage: 100, + }) .pipe( getFirstSucceededRemoteListPayload(), map( (groups: Group[]) => { diff --git a/src/app/access-control/group-registry/group-page.guard.spec.ts b/src/app/access-control/group-registry/group-page.guard.spec.ts index 48fa124c077..3024e42d64a 100644 --- a/src/app/access-control/group-registry/group-page.guard.spec.ts +++ b/src/app/access-control/group-registry/group-page.guard.spec.ts @@ -1,10 +1,24 @@ -import { GroupPageGuard } from './group-page.guard'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { ActivatedRouteSnapshot, Router } from '@angular/router'; -import { of as observableOf } from 'rxjs'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + Router, + UrlTree, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../core/auth/auth.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { groupPageGuard } from './group-page.guard'; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; // Increase timeout to 10 seconds describe('GroupPageGuard', () => { const groupsEndpointUrl = 'https://test.org/api/eperson/groups'; @@ -13,47 +27,59 @@ describe('GroupPageGuard', () => { const routeSnapshotWithGroupId = { params: { groupId: groupUuid, - } + }, } as unknown as ActivatedRouteSnapshot; - let guard: GroupPageGuard; let halEndpointService: HALEndpointService; let authorizationService: AuthorizationDataService; let router: Router; let authService: AuthService; - beforeEach(() => { + function init() { halEndpointService = jasmine.createSpyObj(['getEndpoint']); - (halEndpointService as any).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl)); + ( halEndpointService as any ).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl)); authorizationService = jasmine.createSpyObj(['isAuthorized']); // NOTE: value is set in beforeEach router = jasmine.createSpyObj(['parseUrl']); - (router as any).parseUrl.and.returnValue = {}; + ( router as any ).parseUrl.and.returnValue = {}; authService = jasmine.createSpyObj(['isAuthenticated']); - (authService as any).isAuthenticated.and.returnValue(observableOf(true)); + ( authService as any ).isAuthenticated.and.returnValue(observableOf(true)); - guard = new GroupPageGuard(halEndpointService, authorizationService, router, authService); - }); + TestBed.configureTestingModule({ + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + { provide: HALEndpointService, useValue: halEndpointService }, + ], + }); + } + + beforeEach(waitForAsync(() => { + init(); + })); it('should be created', () => { - expect(guard).toBeTruthy(); + expect(groupPageGuard).toBeTruthy(); }); describe('canActivate', () => { describe('when the current user can manage the group', () => { beforeEach(() => { - (authorizationService as any).isAuthorized.and.returnValue(observableOf(true)); + ( authorizationService as any ).isAuthorized.and.returnValue(observableOf(true)); }); it('should return true', (done) => { - guard.canActivate( - routeSnapshotWithGroupId, { url: 'current-url'} as any - ).subscribe((result) => { + const result$ = TestBed.runInInjectionContext(() => { + return groupPageGuard()(routeSnapshotWithGroupId, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe((result) => { expect(authorizationService.isAuthorized).toHaveBeenCalledWith( - FeatureID.CanManageGroup, groupEndpointUrl, undefined + FeatureID.CanManageGroup, groupEndpointUrl, undefined, ); expect(result).toBeTrue(); done(); @@ -67,15 +93,18 @@ describe('GroupPageGuard', () => { }); it('should not return true', (done) => { - guard.canActivate( - routeSnapshotWithGroupId, { url: 'current-url'} as any - ).subscribe((result) => { + const result$ = TestBed.runInInjectionContext(() => { + return groupPageGuard()(routeSnapshotWithGroupId, { url: 'current-url' } as any); + }) as Observable; + + result$.subscribe((result) => { expect(authorizationService.isAuthorized).toHaveBeenCalledWith( - FeatureID.CanManageGroup, groupEndpointUrl, undefined + FeatureID.CanManageGroup, groupEndpointUrl, undefined, ); expect(result).not.toBeTrue(); done(); }); + }); }); }); diff --git a/src/app/access-control/group-registry/group-page.guard.ts b/src/app/access-control/group-registry/group-page.guard.ts index 057f67ddeb9..c52bed9c48b 100644 --- a/src/app/access-control/group-registry/group-page.guard.ts +++ b/src/app/access-control/group-registry/group-page.guard.ts @@ -1,35 +1,38 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { map } from 'rxjs/operators'; -@Injectable({ - providedIn: 'root' -}) -export class GroupPageGuard extends SomeFeatureAuthorizationGuard { - - protected groupsEndpoint = 'groups'; - - constructor(protected halEndpointService: HALEndpointService, - protected authorizationService: AuthorizationDataService, - protected router: Router, - protected authService: AuthService) { - super(authorizationService, router, authService); - } +import { + someFeatureAuthorizationGuard, + StringGuardParamFn, +} from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; - getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf([FeatureID.CanManageGroup]); - } +const defaultGroupPageGetObjectUrl: StringGuardParamFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +): Observable => { + const halEndpointService = inject(HALEndpointService); + const groupsEndpoint = 'groups'; - getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe( - map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`) - ); - } + return halEndpointService.getEndpoint(groupsEndpoint).pipe( + map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`), + ); +}; -} +export const groupPageGuard = ( + getObjectUrl = defaultGroupPageGetObjectUrl, + getEPersonUuid?: StringGuardParamFn, +): CanActivateFn => someFeatureAuthorizationGuard( + () => observableOf([FeatureID.CanManageGroup]), + getObjectUrl, + getEPersonUuid); 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 8144bd05995..d1bc62a95cc 100644 --- a/src/app/access-control/group-registry/group-registry.actions.ts +++ b/src/app/access-control/group-registry/group-registry.actions.ts @@ -1,5 +1,6 @@ /* 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'; diff --git a/src/app/access-control/group-registry/group-registry.reducers.spec.ts b/src/app/access-control/group-registry/group-registry.reducers.spec.ts index de5b65f5ba6..83a6df580d5 100644 --- a/src/app/access-control/group-registry/group-registry.reducers.spec.ts +++ b/src/app/access-control/group-registry/group-registry.reducers.spec.ts @@ -1,6 +1,12 @@ import { GroupMock } from '../../shared/testing/group-mock'; -import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction } from './group-registry.actions'; -import { groupRegistryReducer, GroupRegistryState } from './group-registry.reducers'; +import { + GroupRegistryCancelGroupAction, + GroupRegistryEditGroupAction, +} from './group-registry.actions'; +import { + groupRegistryReducer, + GroupRegistryState, +} from './group-registry.reducers'; const initialState: GroupRegistryState = { editGroup: null, diff --git a/src/app/access-control/group-registry/group-registry.reducers.ts b/src/app/access-control/group-registry/group-registry.reducers.ts index 8e288b7f3a4..0bb3ad4b5c2 100644 --- a/src/app/access-control/group-registry/group-registry.reducers.ts +++ b/src/app/access-control/group-registry/group-registry.reducers.ts @@ -1,5 +1,9 @@ import { Group } from '../../core/eperson/models/group.model'; -import { GroupRegistryAction, GroupRegistryActionTypes, GroupRegistryEditGroupAction } from './group-registry.actions'; +import { + GroupRegistryAction, + GroupRegistryActionTypes, + GroupRegistryEditGroupAction, +} from './group-registry.actions'; /** * The metadata registry state. @@ -27,13 +31,13 @@ export function groupRegistryReducer(state = initialState, action: GroupRegistry case GroupRegistryActionTypes.EDIT_GROUP: { return Object.assign({}, state, { - editGroup: (action as GroupRegistryEditGroupAction).group + editGroup: (action as GroupRegistryEditGroupAction).group, }); } case GroupRegistryActionTypes.CANCEL_EDIT_GROUP: { return Object.assign({}, state, { - editGroup: null + editGroup: null, }); } 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 828aadc95a4..4150e2560c5 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -2,17 +2,17 @@
- +

{{messagePrefix + 'head' | translate}}

- +
@@ -33,11 +33,10 @@
- + @@ -70,7 +69,7 @@
-
+ + + + + + + + + + + + + + + +
+ {{ header }} +
+ {{ getLabel(point) | async }} + + {{ point.values[header] }} +
+
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts index c7900cd2785..105a7623d6b 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts +++ b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts @@ -1,12 +1,16 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { StatisticsTableComponent } from './statistics-table.component'; -import { UsageReport } from '../../core/statistics/models/usage-report.model'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; + import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { StatisticsTableComponent } from './statistics-table.component'; describe('StatisticsTableComponent', () => { @@ -18,8 +22,6 @@ describe('StatisticsTableComponent', () => { TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - ], - declarations: [ StatisticsTableComponent, ], providers: [ @@ -27,7 +29,7 @@ describe('StatisticsTableComponent', () => { { provide: DSONameService, useValue: {} }, ], }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { @@ -69,8 +71,8 @@ describe('StatisticsTableComponent', () => { views: 8, downloads: 8, }, - } - ] + }, + ], }); component.ngOnInit(); fixture.detectChanges(); diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.ts b/src/app/statistics-page/statistics-table/statistics-table.component.ts index 45924caa8d1..cd717305681 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.ts +++ b/src/app/statistics-page/statistics-table/statistics-table.component.ts @@ -1,11 +1,33 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { Point, UsageReport } from '../../core/statistics/models/usage-report.model'; -import { Observable, of } from 'rxjs'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { + AsyncPipe, + NgFor, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of, +} from 'rxjs'; import { map } from 'rxjs/operators'; -import { getRemoteDataPayload, getFinishedRemoteData } from '../../core/shared/operators'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { TranslateService } from '@ngx-translate/core'; +import { + getFinishedRemoteData, + getRemoteDataPayload, +} from '../../core/shared/operators'; +import { + Point, + UsageReport, +} from '../../core/statistics/models/usage-report.model'; import { isEmpty } from '../../shared/empty.util'; /** @@ -14,7 +36,9 @@ import { isEmpty } from '../../shared/empty.util'; @Component({ selector: 'ds-statistics-table', templateUrl: './statistics-table.component.html', - styleUrls: ['./statistics-table.component.scss'] + styleUrls: ['./statistics-table.component.scss'], + standalone: true, + imports: [NgIf, NgFor, AsyncPipe, TranslateModule], }) export class StatisticsTableComponent implements OnInit { diff --git a/src/app/statistics/angulartics/dspace-provider.spec.ts b/src/app/statistics/angulartics/dspace-provider.spec.ts index 1ec7a9f06bc..80176c3e949 100644 --- a/src/app/statistics/angulartics/dspace-provider.spec.ts +++ b/src/app/statistics/angulartics/dspace-provider.spec.ts @@ -1,8 +1,9 @@ -import { Angulartics2DSpace } from './dspace-provider'; import { Angulartics2 } from 'angulartics2'; -import { StatisticsService } from '../statistics.service'; -import { filter } from 'rxjs/operators'; import { of as observableOf } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { StatisticsService } from '../statistics.service'; +import { Angulartics2DSpace } from './dspace-provider'; describe('Angulartics2DSpace', () => { let provider: Angulartics2DSpace; @@ -11,13 +12,13 @@ describe('Angulartics2DSpace', () => { beforeEach(() => { angulartics2 = { - eventTrack: observableOf({action: 'page_view', properties: { + eventTrack: observableOf({ action: 'page_view', properties: { object: 'mock-object', - referrer: 'https://www.referrer.com' - }}), - filterDeveloperMode: () => filter(() => true) + referrer: 'https://www.referrer.com', + } }), + filterDeveloperMode: () => filter(() => true), } as any; - statisticsService = jasmine.createSpyObj('statisticsService', {trackViewEvent: null}); + statisticsService = jasmine.createSpyObj('statisticsService', { trackViewEvent: null }); provider = new Angulartics2DSpace(angulartics2, statisticsService); }); diff --git a/src/app/statistics/angulartics/dspace-provider.ts b/src/app/statistics/angulartics/dspace-provider.ts index 86c16b5c011..c4143bbf970 100644 --- a/src/app/statistics/angulartics/dspace-provider.ts +++ b/src/app/statistics/angulartics/dspace-provider.ts @@ -1,11 +1,15 @@ import { Injectable } from '@angular/core'; -import { Angulartics2 } from 'angulartics2'; +import { + Angulartics2, + EventTrack, +} from 'angulartics2'; + import { StatisticsService } from '../statistics.service'; /** * Angulartics2DSpace is a angulartics2 plugin that provides DSpace with the events. */ -@Injectable({providedIn: 'root'}) +@Injectable({ providedIn: 'root' }) export class Angulartics2DSpace { constructor( @@ -23,7 +27,7 @@ export class Angulartics2DSpace { .subscribe((event) => this.eventTrack(event)); } - private eventTrack(event) { + private eventTrack(event: Partial): void { if (event.action === 'page_view') { this.statisticsService.trackViewEvent(event.properties.object, event.properties.referrer); } else if (event.action === 'search') { @@ -32,7 +36,7 @@ export class Angulartics2DSpace { event.properties.page, event.properties.sort, event.properties.filters, - event.properties.clickedObject, + event.properties.clickedObject?.split('?')[0], ); } } diff --git a/src/app/statistics/angulartics/dspace/view-tracker-resolver.service.ts b/src/app/statistics/angulartics/dspace/view-tracker-resolver.service.ts new file mode 100644 index 00000000000..004864cc68d --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker-resolver.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveEnd, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { Angulartics2 } from 'angulartics2'; +import { switchMap } from 'rxjs'; +import { + filter, + take, +} from 'rxjs/operators'; + +import { ReferrerService } from '../../../core/services/referrer.service'; + +/** + * This component triggers a page view statistic + */ +@Injectable({ + providedIn: 'root', +}) +export class ViewTrackerResolverService { + + constructor( + public angulartics2: Angulartics2, + public referrerService: ReferrerService, + public router: Router, + ) { + } + + resolve(routeSnapshot: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + const dsoPath = routeSnapshot.data.dsoPath || 'dso.payload'; // Fetch the resolvers passed via the route data + this.router.events.pipe( + filter(event => event instanceof ResolveEnd), + take(1), + switchMap(() => + this.referrerService.getReferrer().pipe(take(1)))) + .subscribe((referrer: string) => { + this.angulartics2.eventTrack.next({ + action: 'page_view', + properties: { + object: this.getNestedProperty(routeSnapshot.data, dsoPath), + referrer, + }, + }); + }); + return true; + } + + private getNestedProperty(obj: any, path: string) { + const keys = path.split('.'); + let result = obj; + + for (const key of keys) { + if (result && result.hasOwnProperty(key)) { + result = result[key]; + } else { + return undefined; + } + } + return result; + } +} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.html b/src/app/statistics/angulartics/dspace/view-tracker.component.html deleted file mode 100644 index c0c0ffe1810..00000000000 --- a/src/app/statistics/angulartics/dspace/view-tracker.component.html +++ /dev/null @@ -1 +0,0 @@ -  diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.scss b/src/app/statistics/angulartics/dspace/view-tracker.component.scss deleted file mode 100644 index c76cafbe449..00000000000 --- a/src/app/statistics/angulartics/dspace/view-tracker.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: none -} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.ts b/src/app/statistics/angulartics/dspace/view-tracker.component.ts deleted file mode 100644 index 805d311cfd9..00000000000 --- a/src/app/statistics/angulartics/dspace/view-tracker.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, Input, OnInit, OnDestroy } from '@angular/core'; -import { Angulartics2 } from 'angulartics2'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Subscription } from 'rxjs/internal/Subscription'; -import { take } from 'rxjs/operators'; -import { hasValue } from '../../../shared/empty.util'; -import { ReferrerService } from '../../../core/services/referrer.service'; - -/** - * This component triggers a page view statistic - */ -@Component({ - selector: 'ds-view-tracker', - styleUrls: ['./view-tracker.component.scss'], - templateUrl: './view-tracker.component.html', -}) -export class ViewTrackerComponent implements OnInit, OnDestroy { - /** - * The DSpaceObject to track a view event about - */ - @Input() object: DSpaceObject; - - /** - * The subscription on this.referrerService.getReferrer() - * @protected - */ - protected sub: Subscription; - - constructor( - public angulartics2: Angulartics2, - public referrerService: ReferrerService - ) { - } - - ngOnInit(): void { - this.sub = this.referrerService.getReferrer() - .pipe(take(1)) - .subscribe((referrer: string) => { - this.angulartics2.eventTrack.next({ - action: 'page_view', - properties: { - object: this.object, - referrer - }, - }); - }); - } - - ngOnDestroy(): void { - // unsubscribe in the case that this component is destroyed before - // this.referrerService.getReferrer() has emitted - if (hasValue(this.sub)) { - this.sub.unsubscribe(); - } - } -} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.resolver.ts b/src/app/statistics/angulartics/dspace/view-tracker.resolver.ts new file mode 100644 index 00000000000..78b6bb6f8a3 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.resolver.ts @@ -0,0 +1,16 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; + +import { ViewTrackerResolverService } from './view-tracker-resolver.service'; + +export const viewTrackerResolver: ResolveFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + viewTrackerResolverService: ViewTrackerResolverService = inject(ViewTrackerResolverService), +): boolean => { + return viewTrackerResolverService.resolve(route, state); +}; diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts index 2465e4db0ef..eb35750c4ca 100644 --- a/src/app/statistics/google-analytics.service.spec.ts +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -4,12 +4,15 @@ import { } from 'angulartics2'; import { of } from 'rxjs'; -import { GoogleAnalyticsService } from './google-analytics.service'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { KlaroService } from '../shared/cookies/klaro.service'; import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../shared/remote-data.utils'; +import { GoogleAnalyticsService } from './google-analytics.service'; describe('GoogleAnalyticsService', () => { const trackingIdProp = 'google.analytics.key'; @@ -45,7 +48,7 @@ describe('GoogleAnalyticsService', () => { ]); klaroServiceSpy = jasmine.createSpyObj('KlaroService', { - 'getSavedPreferences': jasmine.createSpy('getSavedPreferences') + 'getSavedPreferences': jasmine.createSpy('getSavedPreferences'), }); configSpy = createConfigSuccessSpy(trackingIdV4TestValue); @@ -54,7 +57,7 @@ describe('GoogleAnalyticsService', () => { set src(newVal) { /* noop */ }, get src() { return innerHTMLTestValue; }, set innerHTML(newVal) { /* noop */ }, - get innerHTML() { return srcTestValue; } + get innerHTML() { return srcTestValue; }, }; innerHTMLSpy = spyOnProperty(scriptElementMock, 'innerHTML', 'set'); @@ -71,7 +74,7 @@ describe('GoogleAnalyticsService', () => { }); klaroServiceSpy.getSavedPreferences.and.returnValue(of({ - GOOGLE_ANALYTICS_KLARO_KEY: true + GOOGLE_ANALYTICS_KLARO_KEY: true, })); service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy ); @@ -95,7 +98,7 @@ describe('GoogleAnalyticsService', () => { }); klaroServiceSpy.getSavedPreferences.and.returnValue(of({ - GOOGLE_ANALYTICS_KLARO_KEY: true + GOOGLE_ANALYTICS_KLARO_KEY: true, })); service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy); @@ -118,7 +121,7 @@ describe('GoogleAnalyticsService', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(); klaroServiceSpy.getSavedPreferences.and.returnValue(of({ - [GOOGLE_ANALYTICS_KLARO_KEY]: true + [GOOGLE_ANALYTICS_KLARO_KEY]: true, })); service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy); }); @@ -159,7 +162,7 @@ describe('GoogleAnalyticsService', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(trackingIdV4TestValue); klaroServiceSpy.getSavedPreferences.and.returnValue(of({ - [GOOGLE_ANALYTICS_KLARO_KEY]: false + [GOOGLE_ANALYTICS_KLARO_KEY]: false, })); service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy); }); @@ -181,7 +184,7 @@ describe('GoogleAnalyticsService', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(trackingIdV4TestValue); klaroServiceSpy.getSavedPreferences.and.returnValue(of({ - [GOOGLE_ANALYTICS_KLARO_KEY]: true + [GOOGLE_ANALYTICS_KLARO_KEY]: true, })); service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy); }); @@ -218,7 +221,7 @@ describe('GoogleAnalyticsService', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(trackingIdV3TestValue); klaroServiceSpy.getSavedPreferences.and.returnValue(of({ - [GOOGLE_ANALYTICS_KLARO_KEY]: true + [GOOGLE_ANALYTICS_KLARO_KEY]: true, })); service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy); }); diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts index 9d32a610936..508297c3b9a 100644 --- a/src/app/statistics/google-analytics.service.ts +++ b/src/app/statistics/google-analytics.service.ts @@ -1,6 +1,8 @@ import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; - +import { + Inject, + Injectable, +} from '@angular/core'; import { Angulartics2GoogleAnalytics, Angulartics2GoogleGlobalSiteTag, @@ -9,9 +11,9 @@ import { combineLatest } from 'rxjs'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; -import { isEmpty } from '../shared/empty.util'; import { KlaroService } from '../shared/cookies/klaro.service'; import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration'; +import { isEmpty } from '../shared/empty.util'; /** * Set up Google Analytics on the client side. diff --git a/src/app/statistics/statistics-endpoint.model.ts b/src/app/statistics/statistics-endpoint.model.ts index 0a3671432e4..8bee88c7dab 100644 --- a/src/app/statistics/statistics-endpoint.model.ts +++ b/src/app/statistics/statistics-endpoint.model.ts @@ -1,10 +1,14 @@ -import { HALLink } from '../core/shared/hal-link.model'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + import { typedObject } from '../core/cache/builders/build-decorators'; -import { excludeFromEquals } from '../core/utilities/equals.decorators'; -import { autoserialize, deserialize } from 'cerialize'; +import { CacheableObject } from '../core/cache/cacheable-object.model'; +import { HALLink } from '../core/shared/hal-link.model'; import { ResourceType } from '../core/shared/resource-type'; +import { excludeFromEquals } from '../core/utilities/equals.decorators'; import { STATISTICS_ENDPOINT } from './statistics-endpoint.resource-type'; -import { CacheableObject } from '../core/cache/cacheable-object.model'; /** * Model class for the statistics endpoint diff --git a/src/app/statistics/statistics.module.ts b/src/app/statistics/statistics.module.ts deleted file mode 100644 index 4870e4fbf0b..00000000000 --- a/src/app/statistics/statistics.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ModuleWithProviders, NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CoreModule } from '../core/core.module'; -import { SharedModule } from '../shared/shared.module'; -import { ViewTrackerComponent } from './angulartics/dspace/view-tracker.component'; -import { StatisticsEndpoint } from './statistics-endpoint.model'; - -/** - * Declaration needed to make sure all decorator functions are called in time - */ -export const models = [ - StatisticsEndpoint -]; - -@NgModule({ - imports: [ - CommonModule, - CoreModule.forRoot(), - SharedModule, - ], - declarations: [ - ViewTrackerComponent, - ], - exports: [ - ViewTrackerComponent, - ] -}) -/** - * This module handles the statistics - */ -export class StatisticsModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: StatisticsModule, - }; - } -} diff --git a/src/app/statistics/statistics.service.spec.ts b/src/app/statistics/statistics.service.spec.ts index 45e954c51c4..ff57e319703 100644 --- a/src/app/statistics/statistics.service.spec.ts +++ b/src/app/statistics/statistics.service.spec.ts @@ -1,11 +1,12 @@ -import { StatisticsService } from './statistics.service'; -import { RequestService } from '../core/data/request.service'; -import { HALEndpointServiceStub } from '../shared/testing/hal-endpoint-service.stub'; -import { getMockRequestService } from '../shared/mocks/request.service.mock'; import isEqual from 'lodash/isEqual'; + +import { RequestService } from '../core/data/request.service'; +import { RestRequest } from '../core/data/rest-request.model'; import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; +import { getMockRequestService } from '../shared/mocks/request.service.mock'; import { SearchOptions } from '../shared/search/models/search-options.model'; -import { RestRequest } from '../core/data/rest-request.model'; +import { HALEndpointServiceStub } from '../shared/testing/hal-endpoint-service.stub'; +import { StatisticsService } from './statistics.service'; describe('StatisticsService', () => { let service: StatisticsService; @@ -25,7 +26,7 @@ describe('StatisticsService', () => { service = initTestService(); it('should send a request to track an item view ', () => { - const mockItem: any = {uuid: 'mock-item-uuid', type: 'item'}; + const mockItem: any = { uuid: 'mock-item-uuid', type: 'item' }; service.trackViewEvent(mockItem, 'https://www.referrer.com'); const request: RestRequest = requestService.send.calls.mostRecent().args[0]; expect(request.body).toBeDefined('request.body'); @@ -48,9 +49,9 @@ describe('StatisticsService', () => { size: 10, totalElements: 248, totalPages: 25, - number: 4 + number: 4, }; - const sort = {by: 'search-field', order: 'ASC'}; + const sort = { by: 'search-field', order: 'ASC' }; service.trackSearchEvent(mockSearch, page, sort); const request: RestRequest = requestService.send.calls.mostRecent().args[0]; const body = JSON.parse(request.body); @@ -64,14 +65,14 @@ describe('StatisticsService', () => { size: 10, totalElements: 248, totalPages: 25, - number: 4 + number: 4, }); }); it('should specify the sort options', () => { expect(body.sort).toEqual({ by: 'search-field', - order: 'asc' + order: 'asc', }); }); }); @@ -84,29 +85,29 @@ describe('StatisticsService', () => { query: 'mock-query', configuration: 'mock-configuration', dsoTypes: [DSpaceObjectType.ITEM], - scope: 'mock-scope' + scope: 'mock-scope', }); const page = { size: 10, totalElements: 248, totalPages: 25, - number: 4 + number: 4, }; - const sort = {by: 'search-field', order: 'ASC'}; + const sort = { by: 'search-field', order: 'ASC' }; const filters = [ { filter: 'title', operator: 'notcontains', value: 'dolor sit', - label: 'dolor sit' + label: 'dolor sit', }, { filter: 'author', operator: 'authority', value: '9zvxzdm4qru17or5a83wfgac', - label: 'Amet, Consectetur' - } + label: 'Amet, Consectetur', + }, ]; service.trackSearchEvent(mockSearch, page, sort, filters); const request: RestRequest = requestService.send.calls.mostRecent().args[0]; @@ -130,14 +131,14 @@ describe('StatisticsService', () => { filter: 'title', operator: 'notcontains', value: 'dolor sit', - label: 'dolor sit' + label: 'dolor sit', }, { filter: 'author', operator: 'authority', value: '9zvxzdm4qru17or5a83wfgac', - label: 'Amet, Consectetur' - } + label: 'Amet, Consectetur', + }, ])).toBe(true); }); }); diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index 56181d233c8..9c63b67be73 100644 --- a/src/app/statistics/statistics.service.ts +++ b/src/app/statistics/statistics.service.ts @@ -1,17 +1,24 @@ -import { RequestService } from '../core/data/request.service'; import { Injectable } from '@angular/core'; +import { + map, + take, +} from 'rxjs/operators'; + +import { RequestService } from '../core/data/request.service'; +import { RestRequest } from '../core/data/rest-request.model'; import { DSpaceObject } from '../core/shared/dspace-object.model'; -import { map, take } from 'rxjs/operators'; -import { TrackRequest } from './track-request.model'; -import { hasValue, isNotEmpty } from '../shared/empty.util'; import { HALEndpointService } from '../core/shared/hal-endpoint.service'; +import { + hasValue, + isNotEmpty, +} from '../shared/empty.util'; import { SearchOptions } from '../shared/search/models/search-options.model'; -import { RestRequest } from '../core/data/rest-request.model'; +import { TrackRequest } from './track-request.model'; /** * The statistics service */ -@Injectable({providedIn: 'root'}) +@Injectable({ providedIn: 'root' }) export class StatisticsService { constructor( @@ -24,7 +31,7 @@ export class StatisticsService { const requestId = this.requestService.generateRequestId(); this.halService.getEndpoint(linkPath).pipe( map((endpoint: string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))), - take(1) // otherwise the previous events will fire again + take(1), // otherwise the previous events will fire again ).subscribe((request: RestRequest) => this.requestService.send(request)); } @@ -35,12 +42,12 @@ export class StatisticsService { */ trackViewEvent( dso: DSpaceObject, - referrer: string + referrer: string, ) { this.sendEvent('/statistics/viewevents', { targetId: dso.uuid, targetType: (dso as any).type, - referrer + referrer, }); } @@ -65,11 +72,11 @@ export class StatisticsService { size: page.size, totalElements: page.totalElements, totalPages: page.totalPages, - number: page.number + number: page.number, }, sort: { by: sort.by, - order: sort.order.toLowerCase() + order: sort.order.toLowerCase(), }, }; if (hasValue(searchOptions.configuration)) { @@ -89,7 +96,7 @@ export class StatisticsService { filter: filter.filter, operator: filter.operator, value: filter.value, - label: filter.label + label: filter.label, }); } Object.assign(body, { appliedFilters: bodyFilters }); diff --git a/src/app/store.actions.ts b/src/app/store.actions.ts index 59ce00a243a..5839778ac05 100644 --- a/src/app/store.actions.ts +++ b/src/app/store.actions.ts @@ -1,6 +1,7 @@ -import { type } from './shared/ngrx/type'; import { Action } from '@ngrx/store'; + import { AppState } from './app.reducer'; +import { type } from './shared/ngrx/type'; export const StoreActionTypes = { REHYDRATE: type('dspace/ngrx/REHYDRATE'), diff --git a/src/app/store.effects.ts b/src/app/store.effects.ts index c7add76add6..4efb940c3d0 100644 --- a/src/app/store.effects.ts +++ b/src/app/store.effects.ts @@ -1,17 +1,24 @@ +import { Injectable } from '@angular/core'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + Store, +} from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { map } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { Action, Store } from '@ngrx/store'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; import { AppState } from './app.reducer'; -import { StoreActionTypes } from './store.actions'; import { HostWindowResizeAction } from './shared/host-window.actions'; +import { StoreActionTypes } from './store.actions'; @Injectable() export class StoreEffects { - replay = createEffect(() => this.actions.pipe( + replay = createEffect(() => this.actions.pipe( ofType(StoreActionTypes.REPLAY), map((replayAction: Action) => { // TODO: should be able to replay all actions before the browser attempts to @@ -21,9 +28,9 @@ export class StoreEffects { return observableOf({}); })), { dispatch: false }); - resize = createEffect(() => this.actions.pipe( + resize = createEffect(() => this.actions.pipe( ofType(StoreActionTypes.REPLAY, StoreActionTypes.REHYDRATE), - map(() => new HostWindowResizeAction(window.innerWidth, window.innerHeight)) + map(() => new HostWindowResizeAction(window.innerWidth, window.innerHeight)), )); constructor(private actions: Actions, private store: Store) { diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 8013162d85c..59f9883f19b 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -1,25 +1,44 @@ -import { waitForAsync, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute, Router } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; - -import { of as observableOf } from 'rxjs'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; - -import { SubmissionEditComponent } from './submission-edit.component'; +import { + ComponentFixture, + fakeAsync, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + of as observableOf, + of, +} from 'rxjs'; + +import { APP_DATA_SERVICES_MAP } from '../../../config/app-config.interface'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; +import { XSRFService } from '../../core/xsrf/xsrf.service'; +import { mockSubmissionObject } from '../../shared/mocks/submission.mock'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { SubmissionService } from '../submission.service'; -import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; - import { RouterStub } from '../../shared/testing/router.stub'; -import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { mockSubmissionObject } from '../../shared/mocks/submission.mock'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { ItemDataService } from '../../core/data/item-data.service'; +import { SectionsServiceStub } from '../../shared/testing/sections-service.stub'; import { SubmissionJsonPatchOperationsServiceStub } from '../../shared/testing/submission-json-patch-operations-service.stub'; -import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; +import { ThemeService } from '../../shared/theme-support/theme.service'; +import { SubmissionFormComponent } from '../form/submission-form.component'; +import { SectionsService } from '../sections/sections.service'; +import { SubmissionService } from '../submission.service'; +import { SubmissionEditComponent } from './submission-edit.component'; describe('SubmissionEditComponent Component', () => { @@ -29,6 +48,9 @@ describe('SubmissionEditComponent Component', () => { let itemDataService: ItemDataService; let submissionJsonPatchOperationsServiceStub: SubmissionJsonPatchOperationsServiceStub; let router: RouterStub; + let halService: jasmine.SpyObj; + + let themeService = getMockThemeService(); const submissionId = '826'; const route: ActivatedRouteStub = new ActivatedRouteStub(); @@ -38,25 +60,39 @@ describe('SubmissionEditComponent Component', () => { itemDataService = jasmine.createSpyObj('itemDataService', { findByHref: createSuccessfulRemoteDataObject$(submissionObject.item), }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of('fake-url'), + }); + TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([ { path: ':id/edit', component: SubmissionEditComponent, pathMatch: 'full' }, - ]) + ]), + SubmissionEditComponent, ], - declarations: [SubmissionEditComponent], providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub }, { provide: SubmissionJsonPatchOperationsService, useClass: SubmissionJsonPatchOperationsServiceStub }, { provide: ItemDataService, useValue: itemDataService }, - { provide: TranslateService, useValue: getMockTranslateService() }, { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: route }, - + { provide: AuthService, useValue: new AuthServiceStub() }, + { provide: HALEndpointService, useValue: halService }, + { provide: SectionsService, useValue: new SectionsServiceStub() }, + { provide: ThemeService, useValue: themeService }, + { provide: XSRFService, useValue: {} }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + provideMockStore(), ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(SubmissionEditComponent, { + remove: { + imports: [ SubmissionFormComponent ], + }, }).compileComponents(); })); @@ -78,8 +114,11 @@ describe('SubmissionEditComponent Component', () => { route.testParams = { id: submissionId }; submissionServiceStub.retrieveSubmission.and.returnValue( - createSuccessfulRemoteDataObject$(submissionObject) + createSuccessfulRemoteDataObject$(submissionObject), ); + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionObject)); + submissionServiceStub.getSubmissionStatus.and.returnValue(observableOf(true)); + fixture.detectChanges(); @@ -94,7 +133,7 @@ describe('SubmissionEditComponent Component', () => { it('should redirect to mydspace when an empty SubmissionObject has been retrieved',() => { route.testParams = { id: submissionId }; - submissionServiceStub.retrieveSubmission.and.returnValue(createSuccessfulRemoteDataObject$({}) + submissionServiceStub.retrieveSubmission.and.returnValue(createSuccessfulRemoteDataObject$({}), ); fixture.detectChanges(); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 93200e5e5c6..822818ee243 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -1,32 +1,57 @@ -import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; - -import { BehaviorSubject, Subscription } from 'rxjs'; -import { debounceTime, filter, switchMap } from 'rxjs/operators'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + ParamMap, + Router, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + debounceTime, + filter, + switchMap, +} from 'rxjs/operators'; -import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; -import { hasValue, isEmpty, isNotEmptyOperator, isNotNull } from '../../shared/empty.util'; import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; -import { SubmissionService } from '../submission.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SubmissionObject } from '../../core/submission/models/submission-object.model'; -import { Collection } from '../../core/shared/collection.model'; +import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; +import { Collection } from '../../core/shared/collection.model'; import { Item } from '../../core/shared/item.model'; import { getAllSucceededRemoteData } from '../../core/shared/operators'; -import { ItemDataService } from '../../core/data/item-data.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; -import parseSectionErrors from '../utils/parseSectionErrors'; +import { + hasValue, + isEmpty, + isNotEmptyOperator, + isNotNull, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionFormComponent } from '../form/submission-form.component'; import { SubmissionError } from '../objects/submission-error.model'; +import { SubmissionService } from '../submission.service'; +import parseSectionErrors from '../utils/parseSectionErrors'; /** * This component allows to edit an existing workspaceitem/workflowitem. */ @Component({ - selector: 'ds-submission-edit', + selector: 'ds-base-submission-edit', styleUrls: ['./submission-edit.component.scss'], - templateUrl: './submission-edit.component.html' + templateUrl: './submission-edit.component.html', + standalone: true, + imports: [ + SubmissionFormComponent, + ], }) export class SubmissionEditComponent implements OnDestroy, OnInit { @@ -123,7 +148,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.route.paramMap.pipe( switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'))), // NOTE new submission is retrieved on the browser side only, so get null on server side rendering - filter((submissionObjectRD: RemoteData) => isNotNull(submissionObjectRD)) + filter((submissionObjectRD: RemoteData) => isNotNull(submissionObjectRD)), ).subscribe((submissionObjectRD: RemoteData) => { if (submissionObjectRD.hasSucceeded) { if (isEmpty(submissionObjectRD.payload)) { @@ -151,7 +176,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.itemLink$.pipe( isNotEmptyOperator(), switchMap((itemLink: string) => - this.itemDataService.findByHref(itemLink) + this.itemDataService.findByHref(itemLink), ), getAllSucceededRemoteData(), // Multiple sources can update the item in quick succession. diff --git a/src/app/submission/edit/themed-submission-edit.component.ts b/src/app/submission/edit/themed-submission-edit.component.ts index bbaf432c134..e1abb0634e1 100644 --- a/src/app/submission/edit/themed-submission-edit.component.ts +++ b/src/app/submission/edit/themed-submission-edit.component.ts @@ -2,13 +2,16 @@ * Themed wrapper for SubmissionEditComponent */ import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { SubmissionEditComponent } from './submission-edit.component'; @Component({ - selector: 'ds-themed-submission-edit', + selector: 'ds-submission-edit', styleUrls: [], - templateUrl: './../../shared/theme-support/themed.component.html' + templateUrl: './../../shared/theme-support/themed.component.html', + standalone: true, + imports: [SubmissionEditComponent], }) export class ThemedSubmissionEditComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html index a78d737640c..db33afc76ac 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.html +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -1,6 +1,6 @@
{{ 'submission.sections.general.collection' | translate }} @@ -25,19 +25,19 @@ class="btn btn-outline-primary" (blur)="onClose()" (click)="onClose()" - [disabled]="(processingChange$ | async) || collectionModifiable == false || isReadonly" + [dsBtnDisabled]="(processingChange$ | async) || collectionModifiable === false || isReadonly" ngbDropdownToggle> - {{ selectedCollectionName$ | async }} + {{ selectedCollectionName$ | async }}
diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index c76de83b833..3a5ae8a18d3 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -1,30 +1,49 @@ -import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; import { By } from '@angular/platform-browser'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; import { cold } from 'jasmine-marbles'; import { of } from 'rxjs'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { mockSubmissionId, mockSubmissionRestResponse } from '../../../shared/mocks/submission.mock'; -import { SubmissionService } from '../../submission.service'; -import { SubmissionFormCollectionComponent } from './submission-form-collection.component'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; import { CommunityDataService } from '../../../core/data/community-data.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { Collection } from '../../../core/shared/collection.model'; import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; +import { ThemedCollectionDropdownComponent } from '../../../shared/collection-dropdown/themed-collection-dropdown.component'; +import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { + mockSubmissionId, + mockSubmissionRestResponse, +} from '../../../shared/mocks/submission.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service.stub'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; import { createTestComponent } from '../../../shared/testing/utils.test'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; import { SectionsService } from '../../sections/sections.service'; -import { Collection } from '../../../core/shared/collection.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionFormCollectionComponent } from './submission-form-collection.component'; describe('SubmissionFormCollectionComponent Component', () => { @@ -46,11 +65,11 @@ describe('SubmissionFormCollectionComponent Component', () => { { key: 'dc.title', language: 'en_US', - value: 'Community 1-Collection 1' + value: 'Community 1-Collection 1', }], _links: { - defaultAccessConditions: collectionId + '/defaultAccessConditions' - } + defaultAccessConditions: collectionId + '/defaultAccessConditions', + }, }); const mockCollectionList = [ @@ -58,71 +77,71 @@ describe('SubmissionFormCollectionComponent Component', () => { communities: [ { id: '123456789-1', - name: 'Community 1' - } + name: 'Community 1', + }, ], collection: { id: '1234567890-1', - name: 'Community 1-Collection 1' - } + name: 'Community 1-Collection 1', + }, }, { communities: [ { id: '123456789-1', - name: 'Community 1' - } + name: 'Community 1', + }, ], collection: { id: '1234567890-2', - name: 'Community 1-Collection 2' - } + name: 'Community 1-Collection 2', + }, }, { communities: [ { id: '123456789-2', - name: 'Community 2' - } + name: 'Community 2', + }, ], collection: { id: '1234567890-3', - name: 'Community 2-Collection 1' - } + name: 'Community 2-Collection 1', + }, }, { communities: [ { id: '123456789-2', - name: 'Community 2' - } + name: 'Community 2', + }, ], collection: { id: '1234567890-4', - name: 'Community 2-Collection 2' - } - } + name: 'Community 2-Collection 2', + }, + }, ]; const communityDataService: any = jasmine.createSpyObj('communityDataService', { - findAll: jasmine.createSpy('findAll') + findAll: jasmine.createSpy('findAll'), }); const collectionDataService: any = jasmine.createSpyObj('collectionDataService', { findById: jasmine.createSpy('findById'), - getAuthorizedCollectionByCommunity: jasmine.createSpy('getAuthorizedCollectionByCommunity') + getAuthorizedCollectionByCommunity: jasmine.createSpy('getAuthorizedCollectionByCommunity'), }); const store: any = jasmine.createSpyObj('store', { dispatch: jasmine.createSpy('dispatch'), - select: jasmine.createSpy('select') + select: jasmine.createSpy('select'), }); const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { - replace: jasmine.createSpy('replace') + replace: jasmine.createSpy('replace'), }); const sectionsService: any = jasmine.createSpyObj('sectionsService', { - isSectionTypeAvailable: of(true) + isSectionTypeAvailable: of(true), }); beforeEach(waitForAsync(() => { @@ -131,11 +150,10 @@ describe('SubmissionFormCollectionComponent Component', () => { FormsModule, ReactiveFormsModule, NgbModule, - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), SubmissionFormCollectionComponent, - TestComponent + TestComponent, + BtnDisabledDirective, ], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, @@ -147,10 +165,14 @@ describe('SubmissionFormCollectionComponent Component', () => { { provide: Store, useValue: store }, { provide: SectionsService, useValue: sectionsService }, ChangeDetectorRef, - SubmissionFormCollectionComponent + SubmissionFormCollectionComponent, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }).compileComponents(); + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(SubmissionFormCollectionComponent, { + remove: { imports: [ThemedCollectionDropdownComponent] }, + }) + .compileComponents(); })); describe('', () => { @@ -255,7 +277,8 @@ describe('SubmissionFormCollectionComponent Component', () => { it('the dropdown button should be disabled when isReadonly is true', () => { comp.isReadonly = true; fixture.detectChanges(); - expect(dropdowBtn.nativeNode.attributes.disabled).toBeDefined(); + expect(dropdowBtn.nativeNode.getAttribute('aria-disabled')).toBe('true'); + expect(dropdowBtn.nativeNode.classList.contains('disabled')).toBeTrue(); }); it('should be simulated when the drop-down menu is closed', () => { @@ -281,7 +304,7 @@ describe('SubmissionFormCollectionComponent Component', () => { expect(submissionServiceStub.changeSubmissionCollection).toHaveBeenCalled(); expect(comp.selectedCollectionId).toBe(mockCollectionList[1].collection.id); expect(comp.selectedCollectionName$).toBeObservable(cold('(a|)', { - a: mockCollectionList[1].collection.name + a: mockCollectionList[1].collection.name, })); }); }); @@ -292,7 +315,11 @@ describe('SubmissionFormCollectionComponent Component', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, + imports: [FormsModule, + ReactiveFormsModule, + NgbModule], }) class TestComponent { diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 964f86577af..1736b474d5f 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -1,35 +1,49 @@ +import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, + OnDestroy, OnInit, Output, SimpleChanges, - ViewChild + ViewChild, } from '@angular/core'; - -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; import { find, - map, mergeMap + map, + mergeMap, } from 'rxjs/operators'; -import { Collection } from '../../../core/shared/collection.model'; -import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; import { RemoteData } from '../../../core/data/remote-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { SubmissionService } from '../../submission.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { CollectionDropdownComponent } from '../../../shared/collection-dropdown/collection-dropdown.component'; +import { ThemedCollectionDropdownComponent } from '../../../shared/collection-dropdown/themed-collection-dropdown.component'; +import { + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { SectionsService } from '../../sections/sections.service'; -import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { SectionsType } from '../../sections/sections-type'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { SubmissionService } from '../../submission.service'; /** * This component allows to show the current collection the submission belonging to and to change it. @@ -37,9 +51,17 @@ import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-submission-form-collection', styleUrls: ['./submission-form-collection.component.scss'], - templateUrl: './submission-form-collection.component.html' + templateUrl: './submission-form-collection.component.html', + standalone: true, + imports: [ + CommonModule, + TranslateModule, + NgbDropdownModule, + ThemedCollectionDropdownComponent, + BtnDisabledDirective, + ], }) -export class SubmissionFormCollectionComponent implements OnChanges, OnInit { +export class SubmissionFormCollectionComponent implements OnDestroy, OnChanges, OnInit { /** * The current collection id this submission belonging to @@ -137,7 +159,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { this.selectedCollectionName$ = this.collectionDataService.findById(this.currentCollectionId).pipe( find((collectionRD: RemoteData) => isNotEmpty(collectionRD.payload)), - map((collectionRD: RemoteData) => this.dsoNameService.getName(collectionRD.payload)) + map((collectionRD: RemoteData) => this.dsoNameService.getName(collectionRD.payload)), ); } } @@ -147,7 +169,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ ngOnInit() { this.pathCombiner = new JsonPatchOperationPathCombiner('sections', 'collection'); - this.available$ = this.sectionsService.isSectionTypeAvailable(this.submissionId, SectionsType.collection); + this.available$ = this.sectionsService.isSectionTypeAvailable(this.submissionId, SectionsType.Collection); } /** @@ -171,20 +193,20 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { this.submissionId, 'sections', 'collection').pipe( - mergeMap((submissionObject: SubmissionObject[]) => { - // retrieve the full submission object with embeds - return this.submissionService.retrieveSubmission(submissionObject[0].id).pipe( - getFirstSucceededRemoteDataPayload() - ); - }) - ).subscribe((submissionObject: SubmissionObject) => { - this.selectedCollectionId = event.collection.id; - this.selectedCollectionName$ = observableOf(event.collection.name); - this.collectionChange.emit(submissionObject); - this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); - this.processingChange$.next(false); - this.cdr.detectChanges(); - }) + mergeMap((submissionObject: SubmissionObject[]) => { + // retrieve the full submission object with embeds + return this.submissionService.retrieveSubmission(submissionObject[0].id).pipe( + getFirstSucceededRemoteDataPayload(), + ); + }), + ).subscribe((submissionObject: SubmissionObject) => { + this.selectedCollectionId = event.collection.id; + this.selectedCollectionName$ = observableOf(event.collection.name); + this.collectionChange.emit(submissionObject); + this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); + this.processingChange$.next(false); + this.cdr.detectChanges(); + }), ); } diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html index e898e1c2207..5c76b2f9dcc 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.html +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -1,20 +1,20 @@ -
-
+
+
-
- +
+ {{'submission.general.info.saved' | translate}} - + {{'submission.general.info.pending-changes' | translate}}
@@ -28,17 +28,17 @@ class="btn btn-secondary" id="save" [attr.data-test]="'save' | dsBrowserOnly" - [disabled]="(processingSaveStatus | async) || !(hasUnsavedModification | async)" + [dsBtnDisabled]="(processingSaveStatus | async) || (hasUnsavedModification | async) !== true" (click)="save($event)"> {{'submission.general.save' | translate}} @@ -47,7 +47,7 @@ id="deposit" [attr.data-test]="'deposit' | dsBrowserOnly" class="btn btn-success" - [disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)" + [dsBtnDisabled]="(processingSaveStatus | async) || (processingDepositStatus | async)" (click)="deposit($event)"> {{'submission.general.deposit' | translate}} diff --git a/src/app/submission/form/footer/submission-form-footer.component.spec.ts b/src/app/submission/form/footer/submission-form-footer.component.spec.ts index dd28f9a10a7..82bc309cc81 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.spec.ts +++ b/src/app/submission/form/footer/submission-form-footer.component.spec.ts @@ -1,21 +1,37 @@ -import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + SimpleChange, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; - -import { TestScheduler } from 'rxjs/testing'; -import { of as observableOf } from 'rxjs'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { + NgbModal, + NgbModule, +} from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { SubmissionRestService } from '../../../core/submission/submission-rest.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { mockSubmissionId } from '../../../shared/mocks/submission.mock'; -import { SubmissionService } from '../../submission.service'; import { SubmissionRestServiceStub } from '../../../shared/testing/submission-rest-service.stub'; -import { SubmissionFormFooterComponent } from './submission-form-footer.component'; -import { SubmissionRestService } from '../../../core/submission/submission-rest.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; import { createTestComponent } from '../../../shared/testing/utils.test'; -import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionFormFooterComponent } from './submission-form-footer.component'; const submissionServiceStub: SubmissionServiceStub = new SubmissionServiceStub(); @@ -33,21 +49,19 @@ describe('SubmissionFormFooterComponent', () => { TestBed.configureTestingModule({ imports: [ NgbModule, - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), SubmissionFormFooterComponent, TestComponent, - BrowserOnlyMockPipe, + BtnDisabledDirective, ], providers: [ { provide: SubmissionService, useValue: submissionServiceStub }, { provide: SubmissionRestService, useClass: SubmissionRestServiceStub }, ChangeDetectorRef, NgbModal, - SubmissionFormFooterComponent + SubmissionFormFooterComponent, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); })); @@ -98,17 +112,17 @@ describe('SubmissionFormFooterComponent', () => { beforeEach(() => { submissionServiceStub.getSubmissionStatus.and.returnValue(hot('-a-b', { a: false, - b: true + b: true, })); submissionServiceStub.getSubmissionSaveProcessingStatus.and.returnValue(hot('-a-b', { a: false, - b: true + b: true, })); submissionServiceStub.getSubmissionDepositProcessingStatus.and.returnValue(hot('-a-b', { a: false, - b: true + b: true, })); }); @@ -116,11 +130,11 @@ describe('SubmissionFormFooterComponent', () => { const expected = cold('-c-d', { c: true, - d: false + d: false, }); comp.ngOnChanges({ - submissionId: new SimpleChange(null, submissionId, true) + submissionId: new SimpleChange(null, submissionId, true), }); fixture.detectChanges(); @@ -132,11 +146,11 @@ describe('SubmissionFormFooterComponent', () => { const expected = cold('-c-d', { c: false, - d: true + d: true, }); comp.ngOnChanges({ - submissionId: new SimpleChange(null, submissionId, true) + submissionId: new SimpleChange(null, submissionId, true), }); fixture.detectChanges(); @@ -148,11 +162,11 @@ describe('SubmissionFormFooterComponent', () => { const expected = cold('-c-d', { c: false, - d: true + d: true, }); comp.ngOnChanges({ - submissionId: new SimpleChange(null, submissionId, true) + submissionId: new SimpleChange(null, submissionId, true), }); fixture.detectChanges(); @@ -215,7 +229,8 @@ describe('SubmissionFormFooterComponent', () => { fixture.detectChanges(); const depositBtn: any = fixture.debugElement.query(By.css('.btn-success')); - expect(depositBtn.nativeElement.disabled).toBeFalsy(); + expect(depositBtn.nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(depositBtn.nativeElement.classList.contains('disabled')).toBeFalse(); }); it('should not have deposit button disabled when submission is valid', () => { @@ -224,7 +239,8 @@ describe('SubmissionFormFooterComponent', () => { fixture.detectChanges(); const depositBtn: any = fixture.debugElement.query(By.css('.btn-success')); - expect(depositBtn.nativeElement.disabled).toBeFalsy(); + expect(depositBtn.nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(depositBtn.nativeElement.classList.contains('disabled')).toBeFalse(); }); it('should disable save button when all modifications had been saved', () => { @@ -232,7 +248,8 @@ describe('SubmissionFormFooterComponent', () => { fixture.detectChanges(); const saveBtn: any = fixture.debugElement.query(By.css('#save')); - expect(saveBtn.nativeElement.disabled).toBeTruthy(); + expect(saveBtn.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(saveBtn.nativeElement.classList.contains('disabled')).toBeTrue(); }); it('should enable save button when there are not saved modifications', () => { @@ -240,7 +257,8 @@ describe('SubmissionFormFooterComponent', () => { fixture.detectChanges(); const saveBtn: any = fixture.debugElement.query(By.css('#save')); - expect(saveBtn.nativeElement.disabled).toBeFalsy(); + expect(saveBtn.nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(saveBtn.nativeElement.classList.contains('disabled')).toBeFalse(); }); }); @@ -249,7 +267,9 @@ describe('SubmissionFormFooterComponent', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, + imports: [NgbModule], }) class TestComponent { diff --git a/src/app/submission/form/footer/submission-form-footer.component.ts b/src/app/submission/form/footer/submission-form-footer.component.ts index 7a11537616d..8645003783c 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.ts +++ b/src/app/submission/form/footer/submission-form-footer.component.ts @@ -1,13 +1,24 @@ -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; - -import { Observable, of as observableOf } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { CommonModule } from '@angular/common'; +import { + Component, + Input, + OnChanges, + SimpleChanges, +} from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { map } from 'rxjs/operators'; import { SubmissionRestService } from '../../../core/submission/submission-rest.service'; -import { SubmissionService } from '../../submission.service'; import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { isNotEmpty } from '../../../shared/empty.util'; +import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; +import { SubmissionService } from '../../submission.service'; /** * This component represents submission form footer bar. @@ -15,7 +26,9 @@ import { isNotEmpty } from '../../../shared/empty.util'; @Component({ selector: 'ds-submission-form-footer', styleUrls: ['./submission-form-footer.component.scss'], - templateUrl: './submission-form-footer.component.html' + templateUrl: './submission-form-footer.component.html', + standalone: true, + imports: [CommonModule, BrowserOnlyPipe, TranslateModule, BtnDisabledDirective], }) export class SubmissionFormFooterComponent implements OnChanges { @@ -72,7 +85,7 @@ export class SubmissionFormFooterComponent implements OnChanges { ngOnChanges(changes: SimpleChanges) { if (isNotEmpty(this.submissionId)) { this.submissionIsInvalid = this.submissionService.getSubmissionStatus(this.submissionId).pipe( - map((isValid: boolean) => isValid === false) + map((isValid: boolean) => isValid === false), ); this.processingSaveStatus = this.submissionService.getSubmissionSaveProcessingStatus(this.submissionId); @@ -112,7 +125,7 @@ export class SubmissionFormFooterComponent implements OnChanges { if (result === 'ok') { this.submissionService.dispatchDiscard(this.submissionId); } - } + }, ); } } diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.html b/src/app/submission/form/section-add/submission-form-section-add.component.html index 939f23209ac..3a046e119df 100644 --- a/src/app/submission/form/section-add/submission-form-section-add.component.html +++ b/src/app/submission/form/section-add/submission-form-section-add.component.html @@ -2,19 +2,21 @@ #sectionAdd="ngbDropdown" placement="bottom-right" class="d-inline-block" - [ngClass]="{'w-100': windowService.isXs()}"> + [ngClass]="{'w-100': isXs$}"> + +
-
diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts index cd6d82c1416..989726e7e4c 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts @@ -1,32 +1,54 @@ -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; + +import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; +import { ThemedCollectionDropdownComponent } from '../../../shared/collection-dropdown/themed-collection-dropdown.component'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; +import { getMockThemeService } from '../../../shared/mocks/theme-service.mock'; import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ThemeService } from '../../../shared/theme-support/theme.service'; import { SubmissionImportExternalCollectionComponent } from './submission-import-external-collection.component'; -import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { By } from '@angular/platform-browser'; describe('SubmissionImportExternalCollectionComponent test suite', () => { let comp: SubmissionImportExternalCollectionComponent; let compAsAny: any; let fixture: ComponentFixture; + let themeService = getMockThemeService(); - beforeEach(waitForAsync (() => { + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - ], - declarations: [ SubmissionImportExternalCollectionComponent, TestComponent, ], providers: [ NgbActiveModal, - SubmissionImportExternalCollectionComponent + SubmissionImportExternalCollectionComponent, + { provide: ThemeService, useValue: themeService }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents().then(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(SubmissionImportExternalCollectionComponent, { + remove: { + imports: [ + ThemedLoadingComponent, + ThemedCollectionDropdownComponent, + ], + }, + }) + .compileComponents().then(); })); // First test to check the correct component creation @@ -70,11 +92,11 @@ describe('SubmissionImportExternalCollectionComponent test suite', () => { const entry = { communities: [ { id: 'community1' }, - { id: 'community2' } + { id: 'community2' }, ], collection: { - id: 'collection' - } + id: 'collection', + }, } as CollectionListEntry; comp.selectObject(entry); @@ -117,13 +139,13 @@ describe('SubmissionImportExternalCollectionComponent test suite', () => { expect(comp.selectedEvent.emit).toHaveBeenCalledWith(selected); }); - it('dropdown should be invisible when the component is loading', fakeAsync(() => { + it('dropdown should be invisible when the component is loading', waitForAsync(() => { spyOn(comp, 'isLoading').and.returnValue(true); fixture.detectChanges(); fixture.whenStable().then(() => { - const dropdownMenu = fixture.debugElement.query(By.css('ds-themed-collection-dropdown')).nativeElement; + const dropdownMenu = fixture.debugElement.query(By.css('ds-collection-dropdown')).nativeElement; expect(dropdownMenu.classList).toContain('d-none'); }); })); @@ -134,7 +156,8 @@ describe('SubmissionImportExternalCollectionComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, }) class TestComponent { diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts index 22430196d43..03bd10cfc29 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts @@ -1,6 +1,18 @@ -import { Component, EventEmitter, Output } from '@angular/core'; -import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; +import { + NgClass, + NgIf, +} from '@angular/common'; +import { + Component, + EventEmitter, + Output, +} from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; +import { ThemedCollectionDropdownComponent } from '../../../shared/collection-dropdown/themed-collection-dropdown.component'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; /** * Wrap component for 'ds-collection-dropdown'. @@ -8,7 +20,15 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'ds-submission-import-external-collection', styleUrls: ['./submission-import-external-collection.component.scss'], - templateUrl: './submission-import-external-collection.component.html' + templateUrl: './submission-import-external-collection.component.html', + imports: [ + ThemedLoadingComponent, + ThemedCollectionDropdownComponent, + TranslateModule, + NgClass, + NgIf, + ], + standalone: true, }) export class SubmissionImportExternalCollectionComponent { /** @@ -31,7 +51,7 @@ export class SubmissionImportExternalCollectionComponent { * @param {NgbActiveModal} activeModal */ constructor( - private activeModal: NgbActiveModal + private activeModal: NgbActiveModal, ) { } /** diff --git a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html index bbb0dbcc942..beecd68d700 100644 --- a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html +++ b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html @@ -18,10 +18,12 @@

{{'submission.import-external.preview.title.' + labelPrefix | translate}}

-
- {{'item.preview.' + metadata.key | translate}} -

{{metadata.value.value}}

-
+

+ {{'item.preview.' + metadata.key | translate}}
+ + {{metadatum.value}}
+
+

diff --git a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.spec.ts b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.spec.ts index f7ff88c3b17..06a048709ab 100644 --- a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.spec.ts +++ b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.spec.ts @@ -1,24 +1,34 @@ -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { Router } from '@angular/router'; - +import { + NgbActiveModal, + NgbModal, +} from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { TestScheduler } from 'rxjs/testing'; -import { of as observableOf } from 'rxjs'; import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; -import { SubmissionImportExternalPreviewComponent } from './submission-import-external-preview.component'; +import { ExternalSourceEntry } from '../../../core/shared/external-source-entry.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { RouterStub } from '../../../shared/testing/router.stub'; -import { SubmissionService } from '../../submission.service'; -import { createTestComponent } from '../../../shared/testing/utils.test'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { ExternalSourceEntry } from '../../../core/shared/external-source-entry.model'; -import { Metadata } from '../../../core/shared/metadata.utils'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { SubmissionService } from '../../submission.service'; import { SubmissionImportExternalCollectionComponent } from '../import-external-collection/submission-import-external-collection.component'; -import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; +import { SubmissionImportExternalPreviewComponent } from './submission-import-external-preview.component'; const externalEntry = Object.assign(new ExternalSourceEntry(), { id: '0001-0001-0001-0001', @@ -27,11 +37,11 @@ const externalEntry = Object.assign(new ExternalSourceEntry(), { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0001' - } - ] + value: 'https://orcid.org/0001-0001-0001-0001', + }, + ], }, - _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } } + _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } }, }); describe('SubmissionImportExternalPreviewComponent test suite', () => { @@ -48,11 +58,9 @@ describe('SubmissionImportExternalPreviewComponent test suite', () => { scheduler = getTestScheduler(); TestBed.configureTestingModule({ imports: [ - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), SubmissionImportExternalPreviewComponent, - TestComponent + TestComponent, ], providers: [ { provide: Router, useValue: new RouterStub() }, @@ -60,9 +68,9 @@ describe('SubmissionImportExternalPreviewComponent test suite', () => { { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: NgbModal, useValue: ngbModal }, { provide: NgbActiveModal, useValue: ngbActiveModal }, - SubmissionImportExternalPreviewComponent + SubmissionImportExternalPreviewComponent, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents().then(); })); @@ -105,7 +113,7 @@ describe('SubmissionImportExternalPreviewComponent test suite', () => { it('Should init component properly', () => { comp.externalSourceEntry = externalEntry; const expected = [ - { key: 'dc.identifier.uri', value: Metadata.first(comp.externalSourceEntry.metadata, 'dc.identifier.uri') } + { key: 'dc.identifier.uri', values: Metadata.all(comp.externalSourceEntry.metadata, 'dc.identifier.uri') }, ]; fixture.detectChanges(); @@ -126,23 +134,23 @@ describe('SubmissionImportExternalPreviewComponent test suite', () => { id: 'dummy', uuid: 'dummy', name: 'dummy', - } + }, ], collection: { id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', name: 'Collection 1', - } + }, }; const submissionObjects = [ - { id: 'jk11k13o-9v4z-632i-sr88-wq071n0h1d47' } + { id: 'jk11k13o-9v4z-632i-sr88-wq071n0h1d47' }, ]; comp.externalSourceEntry = externalEntry; ngbModal.open.and.returnValue({ componentInstance: { selectedEvent: observableOf(emittedEvent) }, close: () => { return; - } + }, }); spyOn(comp, 'closeMetadataModal'); submissionServiceStub.createSubmissionFromExternalSource.and.returnValue(observableOf(submissionObjects)); @@ -162,7 +170,8 @@ describe('SubmissionImportExternalPreviewComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, }) class TestComponent { diff --git a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts index ccc9ede1427..c7f434b23ce 100644 --- a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts +++ b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts @@ -1,14 +1,25 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { NgFor } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; import { Router } from '@angular/router'; -import { NgbActiveModal, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { + NgbActiveModal, + NgbModal, + NgbModalRef, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { mergeMap } from 'rxjs/operators'; + import { ExternalSourceEntry } from '../../../core/shared/external-source-entry.model'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { Metadata } from '../../../core/shared/metadata.utils'; -import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; -import { mergeMap } from 'rxjs/operators'; -import { SubmissionService } from '../../submission.service'; import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; +import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SubmissionService } from '../../submission.service'; import { SubmissionImportExternalCollectionComponent } from '../import-external-collection/submission-import-external-collection.component'; /** @@ -17,7 +28,12 @@ import { SubmissionImportExternalCollectionComponent } from '../import-external- @Component({ selector: 'ds-submission-import-external-preview', styleUrls: ['./submission-import-external-preview.component.scss'], - templateUrl: './submission-import-external-preview.component.html' + templateUrl: './submission-import-external-preview.component.html', + imports: [ + NgFor, + TranslateModule, + ], + standalone: true, }) export class SubmissionImportExternalPreviewComponent implements OnInit { /** @@ -27,7 +43,7 @@ export class SubmissionImportExternalPreviewComponent implements OnInit { /** * The entry metadata list */ - public metadataList: { key: string, value: MetadataValue }[]; + public metadataList: { key: string, values: MetadataValue[] }[]; /** * The label prefix to use to generate the translation label */ @@ -50,7 +66,7 @@ export class SubmissionImportExternalPreviewComponent implements OnInit { private submissionService: SubmissionService, private modalService: NgbModal, private router: Router, - private notificationService: NotificationsService + private notificationService: NotificationsService, ) { } /** @@ -62,7 +78,7 @@ export class SubmissionImportExternalPreviewComponent implements OnInit { metadataKeys.forEach((key) => { this.metadataList.push({ key: key, - value: Metadata.first(this.externalSourceEntry.metadata, key) + values: Metadata.all(this.externalSourceEntry.metadata, key), }); }); } @@ -87,7 +103,7 @@ export class SubmissionImportExternalPreviewComponent implements OnInit { this.modalRef.componentInstance.selectedEvent.pipe( mergeMap((collectionListEntry: CollectionListEntry) => { return this.submissionService.createSubmissionFromExternalSource(this.externalSourceEntry._links.self.href, collectionListEntry.collection.id); - }) + }), ).subscribe((submissionObjects: SubmissionObject[]) => { let isValid = false; if (submissionObjects.length === 1) { diff --git a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html index 535395e5345..f5bde4e81ab 100644 --- a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html +++ b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.html @@ -1,25 +1,28 @@
- +
- -
- -
diff --git a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.spec.ts b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.spec.ts index e1f59c3a5ea..9005dc15893 100644 --- a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.spec.ts +++ b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.spec.ts @@ -1,29 +1,42 @@ -import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; +import { getTestScheduler } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; -import { - SourceElement, - SubmissionImportExternalSearchbarComponent -} from './submission-import-external-searchbar.component'; +import { TestScheduler } from 'rxjs/testing'; + +import { RequestParam } from '../../../core/cache/models/request-param.model'; import { ExternalSourceDataService } from '../../../core/data/external-source-data.service'; -import { createTestComponent } from '../../../shared/testing/utils.test'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; +import { ExternalSource } from '../../../core/shared/external-source.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { HostWindowService } from '../../../shared/host-window.service'; import { externalSourceCiencia, externalSourceMyStaffDb, externalSourceOrcid, - getMockExternalSourceService + getMockExternalSourceService, } from '../../../shared/mocks/external-source.service.mock'; -import { PageInfo } from '../../../core/shared/page-info.model'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { ExternalSource } from '../../../core/shared/external-source.model'; -import { HostWindowService } from '../../../shared/host-window.service'; import { HostWindowServiceStub } from '../../../shared/testing/host-window-service.stub'; -import { getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { RequestParam } from '../../../core/cache/models/request-param.model'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + SourceElement, + SubmissionImportExternalSearchbarComponent, +} from './submission-import-external-searchbar.component'; describe('SubmissionImportExternalSearchbarComponent test suite', () => { let comp: SubmissionImportExternalSearchbarComponent; @@ -42,8 +55,6 @@ describe('SubmissionImportExternalSearchbarComponent test suite', () => { TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - ], - declarations: [ SubmissionImportExternalSearchbarComponent, TestComponent, ], @@ -51,9 +62,9 @@ describe('SubmissionImportExternalSearchbarComponent test suite', () => { { provide: ExternalSourceDataService, useValue: mockExternalSourceService }, ChangeDetectorRef, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - SubmissionImportExternalSearchbarComponent + SubmissionImportExternalSearchbarComponent, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents().then(); })); @@ -91,9 +102,9 @@ describe('SubmissionImportExternalSearchbarComponent test suite', () => { paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); compAsAny.externalService.searchBy.and.returnValue(observableOf(paginatedListRD)); sourceList = [ - {id: 'orcid', name: 'orcid'}, - {id: 'ciencia', name: 'ciencia'}, - {id: 'my_staff_db', name: 'my_staff_db'}, + { id: 'orcid', name: 'orcid' }, + { id: 'ciencia', name: 'ciencia' }, + { id: 'my_staff_db', name: 'my_staff_db' }, ]; }); @@ -124,7 +135,7 @@ describe('SubmissionImportExternalSearchbarComponent test suite', () => { }); it('Variable \'selectedElement\' should be assigned', () => { - const selectedElement = {id: 'orcid', name: 'orcid'}; + const selectedElement = { id: 'orcid', name: 'orcid' }; comp.makeSourceSelection(selectedElement); expect(comp.selectedElement).toEqual(selectedElement); }); @@ -136,14 +147,14 @@ describe('SubmissionImportExternalSearchbarComponent test suite', () => { elementsPerPage: 3, totalElements: 6, totalPages: 2, - currentPage: 0 + currentPage: 0, }); compAsAny.findListOptions = Object.assign({}, new FindListOptions(), { elementsPerPage: 3, currentPage: 0, searchParams: [ - new RequestParam('entityType', 'Publication') - ] + new RequestParam('entityType', 'Publication'), + ], }); comp.sourceList = sourceList; const expected = sourceList.concat(sourceList); @@ -170,7 +181,8 @@ describe('SubmissionImportExternalSearchbarComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, }) class TestComponent { initExternalSourceData = { entity: 'Publication', query: 'dummy', sourceId: 'ciencia' }; diff --git a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.ts b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.ts index 522d71cc228..eb7a703cc2d 100644 --- a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.ts +++ b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.ts @@ -1,19 +1,45 @@ -import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; - -import { Observable, of as observableOf, Subscription } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { CommonModule } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; +import { + catchError, + tap, +} from 'rxjs/operators'; import { RequestParam } from '../../../core/cache/models/request-param.model'; import { ExternalSourceDataService } from '../../../core/data/external-source-data.service'; -import { ExternalSource } from '../../../core/shared/external-source.model'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; +import { ExternalSource } from '../../../core/shared/external-source.model'; +import { + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../../core/shared/operators'; import { PageInfo } from '../../../core/shared/page-info.model'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { HostWindowService } from '../../../shared/host-window.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasValue } from '../../../shared/empty.util'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { HostWindowService } from '../../../shared/host-window.service'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; /** * Interface for the selected external source element. @@ -38,7 +64,16 @@ export interface ExternalSourceData { @Component({ selector: 'ds-submission-import-external-searchbar', styleUrls: ['./submission-import-external-searchbar.component.scss'], - templateUrl: './submission-import-external-searchbar.component.html' + templateUrl: './submission-import-external-searchbar.component.html', + imports: [ + CommonModule, + TranslateModule, + InfiniteScrollModule, + NgbDropdownModule, + FormsModule, + BtnDisabledDirective, + ], + standalone: true, }) export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDestroy { /** @@ -93,7 +128,7 @@ export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDes constructor( private externalService: ExternalSourceDataService, private cdr: ChangeDetectorRef, - protected windowService: HostWindowService + protected windowService: HostWindowService, ) { } @@ -103,7 +138,7 @@ export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDes ngOnInit() { this.selectedElement = { id: '', - name: 'loading' + name: 'loading', }; this.searchString = ''; this.sourceList = []; @@ -111,8 +146,8 @@ export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDes elementsPerPage: 5, currentPage: 1, searchParams: [ - new RequestParam('entityType', this.initExternalSourceData.entity) - ] + new RequestParam('entityType', this.initExternalSourceData.entity), + ], }); this.externalService.searchBy('findByEntityType', this.findListOptions).pipe( catchError(() => { @@ -156,8 +191,8 @@ export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDes elementsPerPage: 5, currentPage: this.findListOptions.currentPage + 1, searchParams: [ - new RequestParam('entityType', this.initExternalSourceData.entity) - ] + new RequestParam('entityType', this.initExternalSourceData.entity), + ], }); this.externalService.searchBy('findByEntityType', this.findListOptions).pipe( catchError(() => { @@ -167,7 +202,7 @@ export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDes return observableOf(paginatedListRD); }), getFirstSucceededRemoteData(), - tap(() => this.sourceListLoading = false) + tap(() => this.sourceListLoading = false), ).subscribe((externalSource: RemoteData>) => { externalSource.payload.page.forEach((element) => { this.sourceList.push({ id: element.id, name: element.name }); @@ -186,8 +221,8 @@ export class SubmissionImportExternalSearchbarComponent implements OnInit, OnDes { entity: this.initExternalSourceData.entity, sourceId: this.selectedElement.id, - query: this.searchString - } + query: this.searchString, + }, ); } diff --git a/src/app/submission/import-external/submission-import-external.component.html b/src/app/submission/import-external/submission-import-external.component.html index dc46e6758fd..d562bfb5e0f 100644 --- a/src/app/submission/import-external/submission-import-external.component.html +++ b/src/app/submission/import-external/submission-import-external.component.html @@ -1,7 +1,7 @@
- +

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

@@ -11,8 +11,8 @@ + {{ 'submission.s [importConfig]="importConfig" (importObject)="import($event)"> - -
- {{ 'search.results.empty' | translate }} + +
+ {{ 'search.results.empty' | translate }}
-
- {{ 'search.results.response.500' | translate }} +
+ {{ 'search.results.response.500' | translate }}
- +

{{'submission.import-external.page.hint' | translate}}

diff --git a/src/app/submission/import-external/submission-import-external.component.spec.ts b/src/app/submission/import-external/submission-import-external.component.spec.ts index 29b75bab446..02450ee7f83 100644 --- a/src/app/submission/import-external/submission-import-external.component.spec.ts +++ b/src/app/submission/import-external/submission-import-external.component.spec.ts @@ -1,33 +1,55 @@ -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; - -import { getTestScheduler } from 'jasmine-marbles'; -import { TranslateModule } from '@ngx-translate/core'; -import { Router } from '@angular/router'; +import { + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { getTestScheduler } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { SubmissionImportExternalComponent } from './submission-import-external.component'; import { ExternalSourceDataService } from '../../core/data/external-source-data.service'; -import { getMockExternalSourceService } from '../../shared/mocks/external-source.service.mock'; -import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; import { RouteService } from '../../core/services/route.service'; -import { createPaginatedList, createTestComponent } from '../../shared/testing/utils.test'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { VarDirective } from '../../shared/utils/var.directive'; -import { routeServiceStub } from '../../shared/testing/route-service.stub'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { ExternalSourceEntry } from '../../core/shared/external-source-entry.model'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { AlertComponent } from '../../shared/alert/alert.component'; +import { HostWindowService } from '../../shared/host-window.service'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { getMockExternalSourceService } from '../../shared/mocks/external-source.service.mock'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { ObjectCollectionComponent } from '../../shared/object-collection/object-collection.component'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ + createSuccessfulRemoteDataObject$, } from '../../shared/remote-data.utils'; -import { ExternalSourceEntry } from '../../core/shared/external-source-entry.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; +import { routeServiceStub } from '../../shared/testing/route-service.stub'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { + createPaginatedList, + createTestComponent, +} from '../../shared/testing/utils.test'; +import { ThemeService } from '../../shared/theme-support/theme.service'; +import { VarDirective } from '../../shared/utils/var.directive'; import { SubmissionImportExternalPreviewComponent } from './import-external-preview/submission-import-external-preview.component'; -import { By } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SubmissionImportExternalSearchbarComponent } from './import-external-searchbar/submission-import-external-searchbar.component'; +import { SubmissionImportExternalComponent } from './submission-import-external.component'; describe('SubmissionImportExternalComponent test suite', () => { let comp: SubmissionImportExternalComponent; @@ -38,12 +60,12 @@ describe('SubmissionImportExternalComponent test suite', () => { const mockSearchOptions = observableOf(new PaginatedSearchOptions({ pagination: Object.assign(new PaginationComponentOptions(), { pageSize: 10, - currentPage: 0 + currentPage: 0, }), - query: 'test' + query: 'test', })); const searchConfigServiceStub = { - paginatedSearchOptions: mockSearchOptions + paginatedSearchOptions: mockSearchOptions, }; const mockExternalSourceService: any = getMockExternalSourceService(); @@ -51,23 +73,35 @@ describe('SubmissionImportExternalComponent test suite', () => { TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - BrowserAnimationsModule - ], - declarations: [ + BrowserAnimationsModule, SubmissionImportExternalComponent, TestComponent, - VarDirective + VarDirective, ], providers: [ { provide: ExternalSourceDataService, useValue: mockExternalSourceService }, { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, { provide: RouteService, useValue: routeServiceStub }, { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: NgbModal, useValue: ngbModal }, - SubmissionImportExternalComponent + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: ThemeService, useValue: getMockThemeService() }, + SubmissionImportExternalComponent, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents().then(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(SubmissionImportExternalComponent, { + remove: { + imports: [ + ObjectCollectionComponent, + ThemedLoadingComponent, + AlertComponent, + SubmissionImportExternalSearchbarComponent, + ], + }, + }) + .compileComponents().then(); })); // First test to check the correct component creation @@ -109,17 +143,17 @@ describe('SubmissionImportExternalComponent test suite', () => { it('Should init component properly (without route data)', () => { const expectedEntries = createSuccessfulRemoteDataObject(createPaginatedList([])); - comp.routeData = {entity: '', sourceId: '', query: '' }; + comp.routeData = { entity: '', sourceId: '', query: '' }; spyOn(compAsAny.routeService, 'getQueryParameterValue').and.returnValue(observableOf('')); fixture.detectChanges(); - expect(comp.routeData).toEqual({entity: '', sourceId: '', query: '' }); + expect(comp.routeData).toEqual({ entity: '', sourceId: '', query: '' }); expect(comp.isLoading$.value).toBe(false); expect(comp.entriesRD$.value).toEqual(expectedEntries); }); it('Should init component properly (with route data)', () => { - comp.routeData = {entity: '', sourceId: '', query: '' }; + comp.routeData = { entity: '', sourceId: '', query: '' }; spyOn(compAsAny, 'retrieveExternalSources'); spyOn(compAsAny.routeService, 'getQueryParameterValue').and.returnValues(observableOf('entity'), observableOf('source'), observableOf('dummy')); fixture.detectChanges(); @@ -145,10 +179,10 @@ describe('SubmissionImportExternalComponent test suite', () => { }); it('Should call \'router.navigate\'', () => { - comp.routeData = {entity: 'Person', sourceId: '', query: '' }; + comp.routeData = { entity: 'Person', sourceId: '', query: '' }; spyOn(compAsAny, 'retrieveExternalSources').and.callFake(() => null); compAsAny.router.navigate.and.returnValue( new Promise(() => {return;})); - const event = {entity: 'Person', sourceId: 'orcidV2', query: 'dummy' }; + const event = { entity: 'Person', sourceId: 'orcidV2', query: 'dummy' }; scheduler.schedule(() => comp.getExternalSourceData(event)); scheduler.flush(); @@ -164,12 +198,12 @@ describe('SubmissionImportExternalComponent test suite', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0001' - } - ] - } + value: 'https://orcid.org/0001-0001-0001-0001', + }, + ], + }, }); - ngbModal.open.and.returnValue({componentInstance: { externalSourceEntry: null}}); + ngbModal.open.and.returnValue({ componentInstance: { externalSourceEntry: null } }); comp.import(entry); expect(compAsAny.modalService.open).toHaveBeenCalledWith(SubmissionImportExternalPreviewComponent, { size: 'lg' }); @@ -193,32 +227,32 @@ describe('SubmissionImportExternalComponent test suite', () => { 'errorMessage': null, 'payload': { 'type': { - 'value': 'paginated-list' + 'value': 'paginated-list', }, 'pageInfo': { 'elementsPerPage': 10, 'totalElements': 11971608, 'totalPages': 1197161, - 'currentPage': 1 + 'currentPage': 1, }, '_links': { 'first': { - 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=0&size=10&sort=id,asc' + 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=0&size=10&sort=id,asc', }, 'self': { - 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?sort=id,ASC&page=0&size=10&query=test' + 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?sort=id,ASC&page=0&size=10&query=test', }, 'next': { - 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=1&size=10&sort=id,asc' + 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=1&size=10&sort=id,asc', }, 'last': { - 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=1197160&size=10&sort=id,asc' + 'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=1197160&size=10&sort=id,asc', }, 'page': [ { - 'href': 'https://example.com/server/api/integration/externalsources/scopus/entryValues/2-s2.0-85130258665' - } - ] + 'href': 'https://example.com/server/api/integration/externalsources/scopus/entryValues/2-s2.0-85130258665', + }, + ], }, 'page': [ { @@ -235,8 +269,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'Silva I.M.M.', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.date.issued': [ { @@ -245,8 +279,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '2024-01-01', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.description.abstract': [ { @@ -255,8 +289,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'This systematic review integrates the data available in the literature regarding the biological activities of the extracts of endophytic fungi isolated from Annona muricata and their secondary metabolites. The search was performed using four electronic databases, and studies’ quality was evaluated using an adapted assessment tool. The initial database search yielded 436 results; ten studies were selected for inclusion. The leaf was the most studied part of the plant (in nine studies); Periconia sp. was the most tested fungus (n = 4); the most evaluated biological activity was anticancer (n = 6), followed by antiviral (n = 3). Antibacterial, antifungal, and antioxidant activities were also tested. Terpenoids or terpenoid hybrid compounds were the most abundant chemical metabolites. Phenolic compounds, esters, alkaloids, saturated and unsaturated fatty acids, aromatic compounds, and peptides were also reported. The selected studies highlighted the biotechnological potentiality of the endophytic fungi extracts from A. muricata. Consequently, it can be considered a promising source of biological compounds with antioxidant effects and active against different microorganisms and cancer cells. Further research is needed involving different plant tissues, other microorganisms, such as SARS-CoV-2, and different cancer cells.', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.identifier.doi': [ { @@ -265,8 +299,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '10.1590/1519-6984.259525', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.identifier.pmid': [ { @@ -275,8 +309,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '35588520', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.identifier.scopus': [ { @@ -285,8 +319,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '2-s2.0-85130258665', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.relation.grantno': [ { @@ -295,8 +329,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'undefined', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.relation.ispartof': [ { @@ -305,8 +339,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'Brazilian Journal of Biology', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.relation.ispartofseries': [ { @@ -315,8 +349,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'Brazilian Journal of Biology', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.relation.issn': [ { @@ -325,8 +359,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '15196984', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.subject': [ { @@ -335,8 +369,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'biological products | biotechnology | mycology | soursop', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.title': [ { @@ -345,8 +379,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'Biological activities of endophytic fungi isolated from Annona muricata Linnaeus: a systematic review', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'dc.type': [ { @@ -355,8 +389,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'Journal', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'oaire.citation.volume': [ { @@ -365,8 +399,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '84', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'oairecerif.affiliation.orgunit': [ { @@ -375,8 +409,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'Universidade Federal do Reconcavo da Bahia', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'oairecerif.citation.number': [ { @@ -385,8 +419,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': 'e259525', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'person.identifier.orcid': [ { @@ -395,8 +429,8 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '#PLACEHOLDER_PARENT_METADATA_VALUE#', 'place': -1, 'authority': null, - 'confidence': -1 - } + 'confidence': -1, + }, ], 'person.identifier.scopus-author-id': [ { @@ -405,19 +439,19 @@ describe('SubmissionImportExternalComponent test suite', () => { 'value': '42561627000', 'place': -1, 'authority': null, - 'confidence': -1 - } - ] + 'confidence': -1, + }, + ], }, '_links': { 'self': { - 'href': 'https://example.com/server/api/integration/externalsources/scopus/entryValues/2-s2.0-85130258665' - } - } - } - ] + 'href': 'https://example.com/server/api/integration/externalsources/scopus/entryValues/2-s2.0-85130258665', + }, + }, + }, + ], }, - 'statusCode': 200 + 'statusCode': 200, }; const errorObj = { errorMessage: 'Http failure response for ' + @@ -425,8 +459,8 @@ describe('SubmissionImportExternalComponent test suite', () => { statusCode: 500, timeCompleted: 1656950434666, errors: [{ - 'message': 'Internal Server Error', 'paths': ['/server/api/integration/externalsources/pubmed/entries'] - }] + 'message': 'Internal Server Error', 'paths': ['/server/api/integration/externalsources/pubmed/entries'], + }], }; beforeEach(() => { fixture = TestBed.createComponent(SubmissionImportExternalComponent); @@ -469,6 +503,12 @@ describe('SubmissionImportExternalComponent test suite', () => { if (param === 'entity') { return observableOf('Publication'); } + if (param === 'query') { + return observableOf('test'); + } + if (param === 'sourceId') { + return observableOf('pubmed'); + } return observableOf({}); }); fixture.detectChanges(); @@ -483,7 +523,7 @@ describe('SubmissionImportExternalComponent test suite', () => { mockExternalSourceService.getExternalSourceEntries.and.returnValue(createFailedRemoteDataObject$( errorObj.errorMessage, errorObj.statusCode, - errorObj.timeCompleted + errorObj.timeCompleted, )); spyOn(routeServiceStub, 'getQueryParameterValue').and.callFake((param) => { if (param === 'entity') { @@ -509,7 +549,8 @@ describe('SubmissionImportExternalComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, }) class TestComponent { diff --git a/src/app/submission/import-external/submission-import-external.component.ts b/src/app/submission/import-external/submission-import-external.component.ts index 25b1d5d1aa2..47b5c09d66c 100644 --- a/src/app/submission/import-external/submission-import-external.component.ts +++ b/src/app/submission/import-external/submission-import-external.component.ts @@ -1,37 +1,85 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; - -import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; -import { filter, mergeMap, switchMap, take, tap } from 'rxjs/operators'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + Router, + RouterLink, +} from '@angular/router'; +import { + NgbModal, + NgbModalRef, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + Subscription, +} from 'rxjs'; +import { + filter, + mergeMap, + switchMap, + take, + tap, +} from 'rxjs/operators'; +import { AlertType } from 'src/app/shared/alert/alert-type'; import { ExternalSourceDataService } from '../../core/data/external-source-data.service'; -import { ExternalSourceData } from './import-external-searchbar/submission-import-external-searchbar.component'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { RouteService } from '../../core/services/route.service'; +import { Context } from '../../core/shared/context.model'; import { ExternalSourceEntry } from '../../core/shared/external-source-entry.model'; +import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type.resource-type'; +import { getFinishedRemoteData } from '../../core/shared/operators'; +import { PageInfo } from '../../core/shared/page-info.model'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { Context } from '../../core/shared/context.model'; +import { AlertComponent } from '../../shared/alert/alert.component'; +import { fadeIn } from '../../shared/animations/fade'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../shared/object-collection/object-collection.component'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { RouteService } from '../../core/services/route.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { SubmissionImportExternalPreviewComponent } from './import-external-preview/submission-import-external-preview.component'; import { - SubmissionImportExternalPreviewComponent -} from './import-external-preview/submission-import-external-preview.component'; -import { fadeIn } from '../../shared/animations/fade'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { getFinishedRemoteData } from '../../core/shared/operators'; -import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type.resource-type'; + ExternalSourceData, + SubmissionImportExternalSearchbarComponent, +} from './import-external-searchbar/submission-import-external-searchbar.component'; /** * This component allows to submit a new workspaceitem importing the data from an external source. */ @Component({ - selector: 'ds-submission-import-external', + selector: 'ds-base-submission-import-external', styleUrls: ['./submission-import-external.component.scss'], templateUrl: './submission-import-external.component.html', - animations: [fadeIn] + animations: [fadeIn], + imports: [ + ObjectCollectionComponent, + ThemedLoadingComponent, + AlertComponent, + NgIf, + AsyncPipe, + SubmissionImportExternalSearchbarComponent, + TranslateModule, + VarDirective, + RouterLink, + ], + standalone: true, }) export class SubmissionImportExternalComponent implements OnInit, OnDestroy { @@ -51,7 +99,7 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { public reload$: BehaviorSubject = new BehaviorSubject({ entity: '', query: '', - sourceId: '' + sourceId: '', }); /** * Configuration to use for the import buttons @@ -74,7 +122,7 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { */ public initialPagination = Object.assign(new PaginationComponentOptions(), { id: 'spc', - pageSize: 10 + pageSize: 10, }); /** * The context to displaying lists for @@ -92,6 +140,8 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { private retrieveExternalSourcesSub: Subscription; + public readonly AlertType = AlertType; + /** * Initialize the component variables. * @param {SearchConfigurationService} searchConfigService @@ -116,9 +166,9 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { this.listId = 'list-submission-external-sources'; this.context = Context.EntitySearchModalWithNameVariants; this.repeatable = false; - this.routeData = {entity: '', sourceId: '', query: ''}; + this.routeData = { entity: '', sourceId: '', query: '' }; this.importConfig = { - buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label + buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label, }; this.entriesRD$ = new BehaviorSubject(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), []))); this.isLoading$ = new BehaviorSubject(false); @@ -126,11 +176,11 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { [ this.routeService.getQueryParameterValue('entity'), this.routeService.getQueryParameterValue('sourceId'), - this.routeService.getQueryParameterValue('query') + this.routeService.getQueryParameterValue('query'), ]).pipe( - take(1) + take(1), ).subscribe(([entity, sourceId, query]: [string, string, string]) => { - this.reload$.next({entity: entity || NONE_ENTITY_TYPE, query: query, sourceId: sourceId}); + this.reload$.next({ entity: entity || NONE_ENTITY_TYPE, query: query, sourceId: sourceId }); this.selectLabel(entity); this.retrieveExternalSources(); })); @@ -144,8 +194,8 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { [], { queryParams: event, - replaceUrl: true - } + replaceUrl: true, + }, ).then(() => { this.reload$.next(event); this.retrieveExternalSources(); @@ -188,16 +238,16 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { this.retrieveExternalSourcesSub = this.reload$.pipe( filter((sourceQueryObject: ExternalSourceData) => isNotEmpty(sourceQueryObject.sourceId) && isNotEmpty(sourceQueryObject.query)), switchMap((sourceQueryObject: ExternalSourceData) => { - const query = sourceQueryObject.query; - this.routeData = sourceQueryObject; - return this.searchConfigService.paginatedSearchOptions.pipe( - tap(() => this.isLoading$.next(true)), - filter((searchOptions) => searchOptions.query === query), - mergeMap((searchOptions) => this.externalService.getExternalSourceEntries(this.routeData.sourceId, searchOptions).pipe( - getFinishedRemoteData(), - )) - ); - } + const query = sourceQueryObject.query; + this.routeData = sourceQueryObject; + return this.searchConfigService.paginatedSearchOptions.pipe( + tap(() => this.isLoading$.next(true)), + filter((searchOptions) => searchOptions.query === query), + mergeMap((searchOptions) => this.externalService.getExternalSourceEntries(this.routeData.sourceId, searchOptions).pipe( + getFinishedRemoteData(), + )), + ); + }, ), ).subscribe((rdData) => { this.entriesRD$.next(rdData); @@ -213,7 +263,7 @@ export class SubmissionImportExternalComponent implements OnInit, OnDestroy { private selectLabel(entity: string): void { this.label = entity; this.importConfig = { - buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label + buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label, }; } diff --git a/src/app/submission/import-external/themed-submission-import-external.component.ts b/src/app/submission/import-external/themed-submission-import-external.component.ts index 698a64842d1..bd7242293d9 100644 --- a/src/app/submission/import-external/themed-submission-import-external.component.ts +++ b/src/app/submission/import-external/themed-submission-import-external.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { SubmissionImportExternalComponent } from './submission-import-external.component'; @@ -6,9 +7,11 @@ import { SubmissionImportExternalComponent } from './submission-import-external. * Themed wrapper for SubmissionImportExternalComponent */ @Component({ - selector: 'ds-themed-submission-import-external', + selector: 'ds-submission-import-external', styleUrls: [], - templateUrl: './../../shared/theme-support/themed.component.html' + templateUrl: './../../shared/theme-support/themed.component.html', + standalone: true, + imports: [SubmissionImportExternalComponent], }) export class ThemedSubmissionImportExternalComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/submission/objects/section-visibility.model.ts b/src/app/submission/objects/section-visibility.model.ts index c41735178c1..16cf16b2ab2 100644 --- a/src/app/submission/objects/section-visibility.model.ts +++ b/src/app/submission/objects/section-visibility.model.ts @@ -5,3 +5,9 @@ export interface SectionVisibility { main: any; other: any; } + + +export enum SectionScope { + Submission = 'SUBMISSION', + Workflow = 'WORKFLOW', +} diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 9182611e479..956c8f95336 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -1,17 +1,20 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; -import { type } from '../../shared/ngrx/type'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { Item } from '../../core/shared/item.model'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { WorkspaceitemSectionUploadFileObject } from '../../core/submission/models/workspaceitem-section-upload-file.model'; import { WorkspaceitemSectionDataType, - WorkspaceitemSectionsObject + WorkspaceitemSectionsObject, } from '../../core/submission/models/workspaceitem-sections.model'; -import { SubmissionObject } from '../../core/submission/models/submission-object.model'; -import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { type } from '../../shared/ngrx/type'; import { SectionsType } from '../sections/sections-type'; -import { Item } from '../../core/shared/item.model'; -import { SectionVisibility } from './section-visibility.model'; +import { + SectionScope, + SectionVisibility, +} from './section-visibility.model'; import { SubmissionError } from './submission-error.model'; import { SubmissionSectionError } from './submission-section-error.model'; @@ -56,9 +59,13 @@ export const SubmissionObjectActionTypes = { DISCARD_SUBMISSION_SUCCESS: type('dspace/submission/DISCARD_SUBMISSION_SUCCESS'), DISCARD_SUBMISSION_ERROR: type('dspace/submission/DISCARD_SUBMISSION_ERROR'), + // Clearing active section types + CLEAN_DUPLICATE_DETECTION: type('dspace/submission/CLEAN_DUPLICATE_DETECTION'), + // Upload file types NEW_FILE: type('dspace/submission/NEW_FILE'), EDIT_FILE_DATA: type('dspace/submission/EDIT_FILE_DATA'), + EDIT_FILE_PRIMARY_BITSTREAM_DATA: type('dspace/submission/EDIT_FILE_PRIMARY_BITSTREAM_DATA'), DELETE_FILE: type('dspace/submission/DELETE_FILE'), // Errors @@ -116,6 +123,7 @@ export class InitSectionAction implements Action { header: string; config: string; mandatory: boolean; + scope: SectionScope; sectionType: SectionsType; visibility: SectionVisibility; enabled: boolean; @@ -136,6 +144,8 @@ export class InitSectionAction implements Action { * the section's config * @param mandatory * the section's mandatory + * @param scope + * the section's scope * @param sectionType * the section's type * @param visibility @@ -148,16 +158,17 @@ export class InitSectionAction implements Action { * the section's errors */ constructor(submissionId: string, - sectionId: string, - header: string, - config: string, - mandatory: boolean, - sectionType: SectionsType, - visibility: SectionVisibility, - enabled: boolean, - data: WorkspaceitemSectionDataType, - errors: SubmissionSectionError[]) { - this.payload = { submissionId, sectionId, header, config, mandatory, sectionType, visibility, enabled, data, errors }; + sectionId: string, + header: string, + config: string, + mandatory: boolean, + scope: SectionScope, + sectionType: SectionsType, + visibility: SectionVisibility, + enabled: boolean, + data: WorkspaceitemSectionDataType, + errors: SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, header, config, mandatory, scope, sectionType, visibility, enabled, data, errors }; } } @@ -177,7 +188,7 @@ export class EnableSectionAction implements Action { * the section's ID to add */ constructor(submissionId: string, - sectionId: string) { + sectionId: string) { this.payload = { submissionId, sectionId }; } } @@ -230,15 +241,34 @@ export class UpdateSectionDataAction implements Action { * the section's metadata */ constructor(submissionId: string, - sectionId: string, - data: WorkspaceitemSectionDataType, - errorsToShow: SubmissionSectionError[], - serverValidationErrors: SubmissionSectionError[], - metadata?: string[]) { + sectionId: string, + data: WorkspaceitemSectionDataType, + errorsToShow: SubmissionSectionError[], + serverValidationErrors: SubmissionSectionError[], + metadata?: string[]) { this.payload = { submissionId, sectionId, data, errorsToShow, serverValidationErrors, metadata }; } } +/** + * Removes data and makes 'detect-duplicate' section not visible. + */ +export class CleanDuplicateDetectionAction implements Action { + type = SubmissionObjectActionTypes.CLEAN_DUPLICATE_DETECTION; + payload: { + submissionId: string; + }; + + /** + * creates a new CleanDetectDuplicateAction + * + * @param submissionId Id of the submission on which perform the action + */ + constructor(submissionId: string ) { + this.payload = { submissionId }; + } +} + export class UpdateSectionDataSuccessAction implements Action { type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA_SUCCESS; } @@ -334,12 +364,12 @@ export class InitSubmissionFormAction implements Action { * the submission's sections errors */ constructor(collectionId: string, - submissionId: string, - selfUrl: string, - submissionDefinition: SubmissionDefinitionsModel, - sections: WorkspaceitemSectionsObject, - item: Item, - errors: SubmissionError) { + submissionId: string, + selfUrl: string, + submissionDefinition: SubmissionDefinitionsModel, + sections: WorkspaceitemSectionsObject, + item: Item, + errors: SubmissionError) { this.payload = { collectionId, submissionId, selfUrl, submissionDefinition, sections, item, errors }; } } @@ -760,6 +790,29 @@ export class NewUploadedFileAction implements Action { } } +export class EditFilePrimaryBitstreamAction implements Action { + type = SubmissionObjectActionTypes.EDIT_FILE_PRIMARY_BITSTREAM_DATA; + payload: { + submissionId: string; + sectionId: string; + fileId: string | null; + }; + + /** + * Edit a file data + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + */ + constructor(submissionId: string, sectionId: string, fileId: string | null) { + this.payload = { submissionId, sectionId, fileId: fileId }; + } +} + export class EditFileDataAction implements Action { type = SubmissionObjectActionTypes.EDIT_FILE_DATA; payload: { @@ -821,6 +874,7 @@ export type SubmissionObjectAction = DisableSectionAction | InitSubmissionFormAction | ResetSubmissionFormAction | CancelSubmissionFormAction + | CleanDuplicateDetectionAction | CompleteInitSubmissionFormAction | ChangeSubmissionCollectionAction | SaveAndDepositSubmissionAction @@ -833,6 +887,7 @@ export type SubmissionObjectAction = DisableSectionAction | SectionStatusChangeAction | NewUploadedFileAction | EditFileDataAction + | EditFilePrimaryBitstreamAction | DeleteUploadedFileAction | InertSectionErrorsAction | DeleteSectionErrorsAction diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index a1bb878aa59..a16dc365f25 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -1,29 +1,35 @@ import { TestBed } from '@angular/core/testing'; - -import { cold, hot } from 'jasmine-marbles'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, + throwError as observableThrowError, +} from 'rxjs'; -import { SubmissionObjectEffects } from './submission-objects.effects'; import { - CompleteInitSubmissionFormAction, - DepositSubmissionAction, - DepositSubmissionErrorAction, - DepositSubmissionSuccessAction, - DiscardSubmissionErrorAction, - DiscardSubmissionSuccessAction, - InitSectionAction, - InitSubmissionFormAction, - SaveForLaterSubmissionFormSuccessAction, - SaveSubmissionFormErrorAction, - SaveSubmissionFormSuccessAction, - SaveSubmissionSectionFormErrorAction, - SaveSubmissionSectionFormSuccessAction, - SubmissionObjectActionTypes, - UpdateSectionDataAction -} from './submission-objects.actions'; + AppState, + storeModuleConfig, +} from '../../app.reducer'; +import { SubmissionSectionModel } from '../../core/config/models/config-submission-section.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { Item } from '../../core/shared/item.model'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; +import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; import { mockSectionsData, mockSectionsDataTwo, @@ -35,27 +41,37 @@ import { mockSubmissionId, mockSubmissionRestResponse, mockSubmissionSelfUrl, - mockSubmissionState + mockSubmissionState, } from '../../shared/mocks/submission.mock'; -import { SubmissionSectionModel } from '../../core/config/models/config-submission-section.model'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { SectionsServiceStub } from '../../shared/testing/sections-service.stub'; +import { StoreMock } from '../../shared/testing/store.mock'; import { SubmissionJsonPatchOperationsServiceStub } from '../../shared/testing/submission-json-patch-operations-service.stub'; -import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; +import { mockSubmissionObjectDataService } from '../../shared/testing/submission-oject-data-service.mock'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; import { SectionsService } from '../sections/sections.service'; -import { SectionsServiceStub } from '../../shared/testing/sections-service.stub'; import { SubmissionService } from '../submission.service'; -import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { StoreMock } from '../../shared/testing/store.mock'; -import { AppState, storeModuleConfig } from '../../app.reducer'; import parseSectionErrors from '../utils/parseSectionErrors'; -import { Item } from '../../core/shared/item.model'; -import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; -import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; -import { mockSubmissionObjectDataService } from '../../shared/testing/submission-oject-data-service.mock'; +import { + CompleteInitSubmissionFormAction, + DepositSubmissionAction, + DepositSubmissionErrorAction, + DepositSubmissionSuccessAction, + DiscardSubmissionErrorAction, + DiscardSubmissionSuccessAction, + InitSectionAction, + InitSubmissionFormAction, + SaveForLaterSubmissionFormSuccessAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, + SubmissionObjectActionTypes, + UpdateSectionDataAction, +} from './submission-objects.actions'; +import { SubmissionObjectEffects } from './submission-objects.effects'; describe('SubmissionObjectEffects test suite', () => { let submissionObjectEffects: SubmissionObjectEffects; @@ -66,6 +82,8 @@ describe('SubmissionObjectEffects test suite', () => { let submissionServiceStub; let submissionJsonPatchOperationsServiceStub; let submissionObjectDataServiceStub; + let workspaceItemDataService; + const collectionId: string = mockSubmissionCollectionId; const submissionId: string = mockSubmissionId; const submissionDefinitionResponse: any = mockSubmissionDefinitionResponse; @@ -82,14 +100,18 @@ describe('SubmissionObjectEffects test suite', () => { submissionServiceStub.hasUnsavedModification.and.returnValue(observableOf(true)); + workspaceItemDataService = jasmine.createSpyObj('WorkspaceItemDataService', { + invalidateById: observableOf(true), + }); + TestBed.configureTestingModule({ imports: [ StoreModule.forRoot({}, storeModuleConfig), TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], providers: [ @@ -106,6 +128,7 @@ describe('SubmissionObjectEffects test suite', () => { { provide: WorkflowItemDataService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, { provide: SubmissionObjectDataService, useValue: submissionObjectDataServiceStub }, + { provide: WorkspaceitemDataService, useValue: workspaceItemDataService }, ], }); @@ -124,10 +147,10 @@ describe('SubmissionObjectEffects test suite', () => { selfUrl: selfUrl, submissionDefinition: submissionDefinition, sections: {}, - item: {metadata: {}}, + item: { metadata: {} }, errors: [], - } - } + }, + }, }); const mappedActions = []; @@ -144,6 +167,7 @@ describe('SubmissionObjectEffects test suite', () => { sectionDefinition.header, config, sectionDefinition.mandatory, + sectionDefinition.scope, sectionDefinition.sectionType, sectionDefinition.visibility, enabled, @@ -159,7 +183,7 @@ describe('SubmissionObjectEffects test suite', () => { e: mappedActions[3], f: mappedActions[4], g: mappedActions[5], - h: mappedActions[6] + h: mappedActions[6], }); expect(submissionObjectEffects.loadForm$).toBeObservable(expected); @@ -179,8 +203,8 @@ describe('SubmissionObjectEffects test suite', () => { sections: {}, item: new Item(), errors: [], - } - } + }, + }, }); const expected = cold('--b-', { @@ -191,8 +215,8 @@ describe('SubmissionObjectEffects test suite', () => { submissionDefinition, {}, new Item(), - null - ) + null, + ), }); expect(submissionObjectEffects.resetForm$).toBeObservable(expected); @@ -205,17 +229,17 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse)); const expected = cold('--b-', { b: new SaveSubmissionFormSuccessAction( submissionId, - mockSubmissionRestResponse as any - ) + mockSubmissionRestResponse as any, + ), }); expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); @@ -227,9 +251,9 @@ describe('SubmissionObjectEffects test suite', () => { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, payload: { submissionId: submissionId, - isManual: true - } - } + isManual: true, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse)); @@ -238,8 +262,8 @@ describe('SubmissionObjectEffects test suite', () => { submissionId, mockSubmissionRestResponse as any, true, - true - ) + true, + ), }); expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); @@ -251,9 +275,9 @@ describe('SubmissionObjectEffects test suite', () => { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, payload: { submissionId: submissionId, - isManual: false - } - } + isManual: false, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse)); @@ -262,8 +286,8 @@ describe('SubmissionObjectEffects test suite', () => { submissionId, mockSubmissionRestResponse as any, false, - false - ) + false, + ), }); expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); @@ -274,18 +298,18 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( - () => observableThrowError('Error') + () => observableThrowError('Error'), ); const expected = cold('--b-', { b: new SaveSubmissionFormErrorAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); @@ -298,17 +322,17 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse)); const expected = cold('--b-', { b: new SaveForLaterSubmissionFormSuccessAction( submissionId, - mockSubmissionRestResponse as any - ) + mockSubmissionRestResponse as any, + ), }); expect(submissionObjectEffects.saveForLaterSubmission$).toBeObservable(expected); @@ -319,18 +343,18 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( - () => observableThrowError('Error') + () => observableThrowError('Error'), ); const expected = cold('--b-', { b: new SaveSubmissionFormErrorAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.saveForLaterSubmission$).toBeObservable(expected); @@ -342,13 +366,13 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a UPDATE_SECTION_DATA action for each updated section', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsData, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { @@ -356,9 +380,9 @@ describe('SubmissionObjectEffects test suite', () => { payload: { submissionId: submissionId, submissionObject: response, - notify: true - } - } + notify: true, + }, + }, }); const errorsList = parseSectionErrors(mockSectionsErrors); @@ -368,21 +392,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, errorsList.traditionalpageone || [], - errorsList.traditionalpageone || [] + errorsList.traditionalpageone || [], ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, errorsList.license || [], - errorsList.license || [] + errorsList.license || [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, errorsList.upload || [], - errorsList.upload || [] + errorsList.upload || [], ), }); @@ -394,20 +418,20 @@ describe('SubmissionObjectEffects test suite', () => { it('should not display errors when notification are disabled and field are not touched', () => { store.nextState({ submission: { - objects: submissionState + objects: submissionState, }, forms: { '2_traditionalpageone': { touched: { - 'dc.title': true - } - } - } + 'dc.title': true, + }, + }, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsData, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { @@ -415,9 +439,9 @@ describe('SubmissionObjectEffects test suite', () => { payload: { submissionId: submissionId, submissionObject: response, - notify: false - } - } + notify: false, + }, + }, }); const errorsToShowList = parseSectionErrors(mockSectionsErrorsTouchedField); @@ -428,21 +452,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, errorsToShowList.traditionalpageone, - serverValidationErrorsList.traditionalpageone + serverValidationErrorsList.traditionalpageone, ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, errorsToShowList.license || [], - serverValidationErrorsList.license || [] + serverValidationErrorsList.license || [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, errorsToShowList.upload || [], - serverValidationErrorsList.upload || [] + serverValidationErrorsList.upload || [], ), }); @@ -453,21 +477,21 @@ describe('SubmissionObjectEffects test suite', () => { it('should display a success notification', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { - sections: mockSectionsData + sections: mockSectionsData, })]; actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: response - } - } + submissionObject: response, + }, + }, }); const expected = cold('--(bcd)-', { @@ -476,21 +500,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, [], - [] + [], ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, [], - [] + [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, [], - [] + [], ), }); @@ -501,22 +525,22 @@ describe('SubmissionObjectEffects test suite', () => { it('should display a warning notification when there are errors', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsData, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: response - } - } + submissionObject: response, + }, + }, }); const errorsList = parseSectionErrors(mockSectionsErrors); @@ -526,21 +550,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, errorsList.traditionalpageone || [], - errorsList.traditionalpageone || [] + errorsList.traditionalpageone || [], ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, errorsList.license || [], - errorsList.license || [] + errorsList.license || [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, errorsList.upload || [], - errorsList.upload || [] + errorsList.upload || [], ), }); @@ -551,22 +575,22 @@ describe('SubmissionObjectEffects test suite', () => { it('should detect and notify a new section', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsDataTwo, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: response - } - } + submissionObject: response, + }, + }, }); const errorsList = parseSectionErrors(mockSectionsErrors); @@ -576,28 +600,28 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsDataTwo.traditionalpageone as any, errorsList.traditionalpageone || [], - errorsList.traditionalpageone || [] + errorsList.traditionalpageone || [], ), c: new UpdateSectionDataAction( submissionId, 'traditionalpagetwo', mockSectionsDataTwo.traditionalpagetwo as any, errorsList.traditionalpagetwo || [], - errorsList.traditionalpagetwo || [] + errorsList.traditionalpagetwo || [], ), d: new UpdateSectionDataAction( submissionId, 'license', mockSectionsDataTwo.license as any, errorsList.license || [], - errorsList.license || [] + errorsList.license || [], ), e: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsDataTwo.upload as any, errorsList.upload || [], - errorsList.upload || [] + errorsList.upload || [], ), }); @@ -612,22 +636,22 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a UPDATE_SECTION_DATA action for each updated section', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsData, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: response - } - } + submissionObject: response, + }, + }, }); const errorsList = parseSectionErrors(mockSectionsErrors); @@ -637,21 +661,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, [], - errorsList.traditionalpageone + errorsList.traditionalpageone, ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, errorsList.license || [], - errorsList.license || [] + errorsList.license || [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, errorsList.upload || [], - errorsList.upload || [] + errorsList.upload || [], ), }); @@ -662,21 +686,21 @@ describe('SubmissionObjectEffects test suite', () => { it('should not display a success notification', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { - sections: mockSectionsData + sections: mockSectionsData, })]; actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: response - } - } + submissionObject: response, + }, + }, }); const expected = cold('--(bcd)-', { @@ -685,21 +709,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, [], - [] + [], ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, [], - [] + [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, [], - [] + [], ), }); @@ -710,22 +734,22 @@ describe('SubmissionObjectEffects test suite', () => { it('should not display a warning notification when there are errors', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsData, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: response - } - } + submissionObject: response, + }, + }, }); const serverValidationErrorsList = parseSectionErrors(mockSectionsErrors); @@ -735,21 +759,21 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsData.traditionalpageone as any, [], - serverValidationErrorsList.traditionalpageone + serverValidationErrorsList.traditionalpageone, ), c: new UpdateSectionDataAction( submissionId, 'license', mockSectionsData.license as any, serverValidationErrorsList.license || [], - serverValidationErrorsList.license || [] + serverValidationErrorsList.license || [], ), d: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsData.upload as any, serverValidationErrorsList.upload || [], - serverValidationErrorsList.upload || [] + serverValidationErrorsList.upload || [], ), }); @@ -760,13 +784,13 @@ describe('SubmissionObjectEffects test suite', () => { it('should detect new sections but not notify for it', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsDataTwo, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; actions = hot('--a-', { a: { @@ -774,8 +798,8 @@ describe('SubmissionObjectEffects test suite', () => { payload: { submissionId: submissionId, submissionObject: response, - } - } + }, + }, }); const errorsList = parseSectionErrors(mockSectionsErrors); @@ -785,28 +809,28 @@ describe('SubmissionObjectEffects test suite', () => { 'traditionalpageone', mockSectionsDataTwo.traditionalpageone as any, [], - errorsList.traditionalpageone + errorsList.traditionalpageone, ), c: new UpdateSectionDataAction( submissionId, 'traditionalpagetwo', mockSectionsDataTwo.traditionalpagetwo as any, errorsList.traditionalpagetwo || [], - errorsList.traditionalpagetwo || [] + errorsList.traditionalpagetwo || [], ), d: new UpdateSectionDataAction( submissionId, 'license', mockSectionsDataTwo.license as any, errorsList.license || [], - errorsList.license || [] + errorsList.license || [], ), e: new UpdateSectionDataAction( submissionId, 'upload', mockSectionsDataTwo.upload as any, errorsList.upload || [], - errorsList.upload || [] + errorsList.upload || [], ), }); @@ -823,17 +847,17 @@ describe('SubmissionObjectEffects test suite', () => { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM, payload: { submissionId: submissionId, - sectionId: 'traditionalpageone' - } - } + sectionId: 'traditionalpageone', + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceID.and.returnValue(observableOf(mockSubmissionRestResponse)); const expected = cold('--b-', { b: new SaveSubmissionSectionFormSuccessAction( submissionId, - mockSubmissionRestResponse as any - ) + mockSubmissionRestResponse as any, + ), }); expect(submissionObjectEffects.saveSection$).toBeObservable(expected); @@ -845,18 +869,18 @@ describe('SubmissionObjectEffects test suite', () => { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM, payload: { submissionId: submissionId, - sectionId: 'traditionalpageone' - } - } + sectionId: 'traditionalpageone', + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceID.and.callFake( - () => observableThrowError('Error') + () => observableThrowError('Error'), ); const expected = cold('--b-', { b: new SaveSubmissionSectionFormErrorAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.saveSection$).toBeObservable(expected); @@ -869,20 +893,20 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); const response = [Object.assign({}, mockSubmissionRestResponse[0], { - sections: mockSectionsDataTwo + sections: mockSectionsDataTwo, })]; submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(response)); const expected = cold('--b-', { b: new DepositSubmissionAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); @@ -892,28 +916,28 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a SAVE_SUBMISSION_FORM_SUCCESS action when there are errors', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); const response = [Object.assign({}, mockSubmissionRestResponse[0], { sections: mockSectionsData, - errors: mockSectionsErrors + errors: mockSectionsErrors, })]; submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(response)); const expected = cold('--b-', { - b: new SaveSubmissionFormSuccessAction(submissionId, response as any[], false, true) + b: new SaveSubmissionFormSuccessAction(submissionId, response as any[], false, true), }); expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); @@ -925,18 +949,18 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( - () => observableThrowError('Error') + () => observableThrowError('Error'), ); const expected = cold('--b-', { b: new SaveSubmissionFormErrorAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); @@ -947,24 +971,24 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a DEPOSIT_SUBMISSION_SUCCESS action on success', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionServiceStub.depositSubmission.and.returnValue(observableOf(mockSubmissionRestResponse)); const expected = cold('--b-', { b: new DepositSubmissionSuccessAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.depositSubmission$).toBeObservable(expected); @@ -973,26 +997,26 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a DEPOSIT_SUBMISSION_ERROR action on error', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionServiceStub.depositSubmission.and.callFake( - () => observableThrowError('Error') + () => observableThrowError('Error'), ); const expected = cold('--b-', { b: new DepositSubmissionErrorAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.depositSubmission$).toBeObservable(expected); @@ -1006,9 +1030,9 @@ describe('SubmissionObjectEffects test suite', () => { type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS, payload: { submissionId: submissionId, - submissionObject: mockSubmissionRestResponse - } - } + submissionObject: mockSubmissionRestResponse, + }, + }, }); submissionObjectEffects.saveForLaterSubmissionSuccess$.subscribe(() => { @@ -1024,9 +1048,9 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionObjectEffects.depositSubmissionSuccess$.subscribe(() => { @@ -1042,9 +1066,9 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionObjectEffects.depositSubmissionError$.subscribe(() => { @@ -1059,9 +1083,9 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionObjectEffects.saveError$.subscribe(() => { @@ -1074,9 +1098,9 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionObjectEffects.saveError$.subscribe(() => { @@ -1089,24 +1113,24 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a DISCARD_SUBMISSION_SUCCESS action on success', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.DISCARD_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionServiceStub.discardSubmission.and.returnValue(observableOf(mockSubmissionRestResponse)); const expected = cold('--b-', { b: new DiscardSubmissionSuccessAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.discardSubmission$).toBeObservable(expected); @@ -1115,26 +1139,26 @@ describe('SubmissionObjectEffects test suite', () => { it('should return a DISCARD_SUBMISSION_ERROR action on error', () => { store.nextState({ submission: { - objects: submissionState - } + objects: submissionState, + }, } as any); actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.DISCARD_SUBMISSION, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionServiceStub.discardSubmission.and.callFake( - () => observableThrowError('Error') + () => observableThrowError('Error'), ); const expected = cold('--b-', { b: new DiscardSubmissionErrorAction( - submissionId - ) + submissionId, + ), }); expect(submissionObjectEffects.discardSubmission$).toBeObservable(expected); @@ -1147,9 +1171,9 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionObjectEffects.discardSubmissionSuccess$.subscribe(() => { @@ -1165,9 +1189,9 @@ describe('SubmissionObjectEffects test suite', () => { a: { type: SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR, payload: { - submissionId: submissionId - } - } + submissionId: submissionId, + }, + }, }); submissionObjectEffects.discardSubmissionError$.subscribe(() => { diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 98646009d5b..f4880fb5898 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -1,27 +1,59 @@ import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import findKey from 'lodash/findKey'; import isEqual from 'lodash/isEqual'; import union from 'lodash/union'; - -import { from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { catchError, filter, map, mergeMap, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { + from as observableFrom, + Observable, + of as observableOf, +} from 'rxjs'; +import { + catchError, + filter, + map, + mergeMap, + switchMap, + take, + tap, + withLatestFrom, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; +import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemSectionDuplicatesObject } from '../../core/submission/models/workspaceitem-section-duplicates.model'; import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; -import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; -import { isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; +import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; +import { + isEmpty, + isNotEmpty, + isNotUndefined, +} from '../../shared/empty.util'; +import { FormState } from '../../shared/form/form.reducer'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SectionsType } from '../sections/sections-type'; +import { followLink } from '../../shared/utils/follow-link-config.model'; import { SectionsService } from '../sections/sections.service'; +import { SectionsType } from '../sections/sections-type'; import { SubmissionState } from '../submission.reducers'; import { SubmissionService } from '../submission.service'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; import parseSectionErrors from '../utils/parseSectionErrors'; import { + CleanDuplicateDetectionAction, CompleteInitSubmissionFormAction, DepositSubmissionAction, DepositSubmissionErrorAction, @@ -43,18 +75,11 @@ import { SubmissionObjectAction, SubmissionObjectActionTypes, UpdateSectionDataAction, - UpdateSectionDataSuccessAction + UpdateSectionDataSuccessAction, } from './submission-objects.actions'; import { SubmissionObjectEntry } from './submission-objects.reducer'; -import { Item } from '../../core/shared/item.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; -import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; -import { FormState } from '../../shared/form/form.reducer'; -import { SubmissionSectionObject } from './submission-section-object.model'; import { SubmissionSectionError } from './submission-section-error.model'; +import { SubmissionSectionObject } from './submission-section-object.model'; @Injectable() export class SubmissionObjectEffects { @@ -71,7 +96,14 @@ export class SubmissionObjectEffects { const selfLink = sectionDefinition._links.self.href || sectionDefinition._links.self; const sectionId = selfLink.substr(selfLink.lastIndexOf('/') + 1); const config = sectionDefinition._links.config ? (sectionDefinition._links.config.href || sectionDefinition._links.config) : ''; - const enabled = (sectionDefinition.mandatory) || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId)); + // A section is enabled if it is mandatory or contains data in its section payload + let enabled = (sectionDefinition.mandatory || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId))); + + // Duplicates will ignore mandatory and display only when "always display" is set or there is data to show + if (sectionDefinition.sectionType === SectionsType.Duplicates) { + enabled = (alwaysDisplayDuplicates() || isNotEmpty((action.payload.sections[sectionId] as WorkspaceitemSectionDuplicatesObject).potentialDuplicates)); + } + let sectionData; if (sectionDefinition.sectionType !== SectionsType.SubmissionForm) { sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); @@ -86,12 +118,13 @@ export class SubmissionObjectEffects { sectionDefinition.header, config, sectionDefinition.mandatory, + sectionDefinition.scope, sectionDefinition.sectionType, sectionDefinition.visibility, enabled, sectionData, - sectionErrors - ) + sectionErrors, + ), ); }); return { action: action, definition: definition, mappedActions: mappedActions }; @@ -99,7 +132,7 @@ export class SubmissionObjectEffects { mergeMap((result) => { return observableFrom( result.mappedActions.concat( - new CompleteInitSubmissionFormAction(result.action.payload.submissionId) + new CompleteInitSubmissionFormAction(result.action.payload.submissionId), )); }))); @@ -116,7 +149,7 @@ export class SubmissionObjectEffects { action.payload.submissionDefinition, action.payload.sections, action.payload.item, - null + null, )))); /** @@ -129,8 +162,8 @@ export class SubmissionObjectEffects { this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, 'sections').pipe( - map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response, action.payload.isManual, action.payload.isManual)), - catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response, action.payload.isManual, action.payload.isManual)), + catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); }))); /** @@ -143,8 +176,8 @@ export class SubmissionObjectEffects { this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, 'sections').pipe( - map((response: SubmissionObject[]) => new SaveForLaterSubmissionFormSuccessAction(action.payload.submissionId, response)), - catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + map((response: SubmissionObject[]) => new SaveForLaterSubmissionFormSuccessAction(action.payload.submissionId, response)), + catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); }))); /** @@ -184,8 +217,8 @@ export class SubmissionObjectEffects { action.payload.submissionId, 'sections', action.payload.sectionId).pipe( - map((response: SubmissionObject[]) => new SaveSubmissionSectionFormSuccessAction(action.payload.submissionId, response)), - catchError(() => observableOf(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId)))); + map((response: SubmissionObject[]) => new SaveSubmissionSectionFormSuccessAction(action.payload.submissionId, response)), + catchError(() => observableOf(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId)))); }))); /** @@ -210,9 +243,9 @@ export class SubmissionObjectEffects { action.payload.submissionId, 'sections') as Observable; } else { - response$ = this.submissionObjectService.findById(action.payload.submissionId, false, true).pipe( + response$ = this.submissionObjectService.findById(action.payload.submissionId, false, true, followLink('item'), followLink('collection')).pipe( getFirstSucceededRemoteDataPayload(), - map((submissionObject: SubmissionObject) => [submissionObject]) + map((submissionObject: SubmissionObject) => [submissionObject]), ); } return response$.pipe( @@ -224,7 +257,7 @@ export class SubmissionObjectEffects { null, this.translate.instant('submission.sections.general.cannot_deposit'), null, - true + true, ); return new SaveSubmissionFormSuccessAction(action.payload.submissionId, response, false, true); } @@ -241,7 +274,7 @@ export class SubmissionObjectEffects { switchMap(([action, state]: [DepositSubmissionAction, any]) => { return this.submissionService.depositSubmission(state.submission.objects[action.payload.submissionId].selfUrl).pipe( map(() => new DepositSubmissionSuccessAction(action.payload.submissionId)), - catchError((error) => observableOf(new DepositSubmissionErrorAction(action.payload.submissionId)))); + catchError((error: unknown) => observableOf(new DepositSubmissionErrorAction(action.payload.submissionId)))); }))); /** @@ -258,6 +291,7 @@ export class SubmissionObjectEffects { depositSubmissionSuccess$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS), tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.deposit_success_notice'))), + tap((action: DepositSubmissionSuccessAction) => this.workspaceItemDataService.invalidateById(action.payload.submissionId)), tap(() => this.submissionService.redirectToMyDSpace())), { dispatch: false }); /** @@ -292,7 +326,7 @@ export class SubmissionObjectEffects { if (section.sectionType === SectionsType.SubmissionForm) { const submissionObject$ = this.submissionObjectService .findById(action.payload.submissionId, true, false, followLink('item')).pipe( - getFirstSucceededRemoteDataPayload() + getFirstSucceededRemoteDataPayload(), ); const item$ = submissionObject$.pipe( @@ -303,7 +337,7 @@ export class SubmissionObjectEffects { return item$.pipe( map((item: Item) => item.metadata), filter((metadata) => !isEqual(action.payload.data, metadata)), - map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errorsToShow, action.payload.serverValidationErrors, action.payload.metadata)) + map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errorsToShow, action.payload.serverValidationErrors, action.payload.metadata)), ); } else { return observableOf(new UpdateSectionDataSuccessAction()); @@ -326,14 +360,17 @@ export class SubmissionObjectEffects { ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR), tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.discard_error_notice')))), { dispatch: false }); - constructor(private actions$: Actions, + constructor( + private actions$: Actions, private notificationsService: NotificationsService, private operationsService: SubmissionJsonPatchOperationsService, private sectionService: SectionsService, private store$: Store, private submissionService: SubmissionService, private submissionObjectService: SubmissionObjectDataService, - private translate: TranslateService) { + private translate: TranslateService, + private workspaceItemDataService: WorkspaceitemDataService, + ) { } /** @@ -434,8 +471,16 @@ export class SubmissionObjectEffects { && isEmpty(sections[sherpaPoliciesSectionId])) { mappedActions.push(new UpdateSectionDataAction(submissionId, sherpaPoliciesSectionId, null, [], [])); } - }); + // When Duplicate Detection step is enabled, add it only if there are duplicates in the response section data + // or if configuration overrides this behaviour + if (!alwaysDisplayDuplicates()) { + const duplicatesSectionId = findKey(currentState.sections, (section) => section.sectionType === SectionsType.Duplicates); + if (isNotUndefined(duplicatesSectionId) && sections.hasOwnProperty(duplicatesSectionId) && isEmpty((sections[duplicatesSectionId] as WorkspaceitemSectionDuplicatesObject).potentialDuplicates)) { + mappedActions.push(new CleanDuplicateDetectionAction(submissionId)); + } + } + }); } return mappedActions; } @@ -464,7 +509,7 @@ function getForm(forms, currentState, sectionId) { * Whether notifications are enabled */ function filterErrors(sectionForm: FormState, sectionErrors: SubmissionSectionError[], sectionType: string, notify: boolean): SubmissionSectionError[] { - if (notify || sectionType !== SectionsType.SubmissionForm) { + if (notify || sectionType !== SectionsType.SubmissionForm.valueOf()) { return sectionErrors; } if (!sectionForm || !sectionForm.touched) { @@ -481,3 +526,7 @@ function filterErrors(sectionForm: FormState, sectionErrors: SubmissionSectionEr }); return filteredErrors; } + +function alwaysDisplayDuplicates(): boolean { + return (environment.submission.duplicateDetection.alwaysShowSection); +} diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts index 2a24afae19c..2bbb875c3cd 100644 --- a/src/app/submission/objects/submission-objects.reducer.spec.ts +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -1,7 +1,16 @@ -import { submissionObjectReducer, SubmissionObjectState } from './submission-objects.reducer'; +import { Item } from '../../core/shared/item.model'; +import { + mockSubmissionCollectionId, + mockSubmissionDefinitionResponse, + mockSubmissionId, + mockSubmissionSelfUrl, + mockSubmissionState, +} from '../../shared/mocks/submission.mock'; +import { SectionsType } from '../sections/sections-type'; import { CancelSubmissionFormAction, ChangeSubmissionCollectionAction, + CleanDuplicateDetectionAction, CompleteInitSubmissionFormAction, DeleteSectionErrorsAction, DeleteUploadedFileAction, @@ -30,17 +39,12 @@ import { SaveSubmissionSectionFormSuccessAction, SectionStatusChangeAction, SubmissionObjectAction, - UpdateSectionDataAction + UpdateSectionDataAction, } from './submission-objects.actions'; -import { SectionsType } from '../sections/sections-type'; import { - mockSubmissionCollectionId, - mockSubmissionDefinitionResponse, - mockSubmissionId, - mockSubmissionSelfUrl, - mockSubmissionState -} from '../../shared/mocks/submission.mock'; -import { Item } from '../../core/shared/item.model'; + submissionObjectReducer, + SubmissionObjectState, +} from './submission-objects.reducer'; describe('submissionReducer test suite', () => { @@ -66,7 +70,7 @@ describe('submissionReducer test suite', () => { isLoading: true, savePending: false, depositPending: false, - } + }, }; const action = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, new Item(), null); @@ -78,8 +82,8 @@ describe('submissionReducer test suite', () => { it('should complete submission initialization', () => { const state = Object.assign({}, initState, { [submissionId]: Object.assign({}, initState[submissionId], { - isLoading: true - }) + isLoading: true, + }), }); const action = new CompleteInitSubmissionFormAction(submissionId); @@ -99,7 +103,7 @@ describe('submissionReducer test suite', () => { isLoading: true, savePending: false, depositPending: false, - } + }, }; const action = new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, {}, submissionDefinition, new Item()); @@ -143,7 +147,7 @@ describe('submissionReducer test suite', () => { const state = Object.assign({}, initState, { [submissionId]: Object.assign({}, initState[submissionId], { savePending: true, - }) + }), }); let action: any = new SaveSubmissionFormSuccessAction(submissionId, []); @@ -191,7 +195,7 @@ describe('submissionReducer test suite', () => { const state = Object.assign({}, initState, { [submissionId]: Object.assign({}, initState[submissionId], { depositPending: true, - }) + }), }); const action: any = new DepositSubmissionSuccessAction(submissionId); @@ -233,6 +237,7 @@ describe('submissionReducer test suite', () => { header: 'submit.progressbar.describe.stepone', config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', mandatory: true, + scope: null, sectionType: 'submission-form', visibility: undefined, collapsed: false, @@ -241,7 +246,7 @@ describe('submissionReducer test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, } as any; let action: any = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, new Item(), null); @@ -253,6 +258,7 @@ describe('submissionReducer test suite', () => { 'submit.progressbar.describe.stepone', 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', true, + null, SectionsType.SubmissionForm, undefined, true, @@ -273,7 +279,7 @@ describe('submissionReducer test suite', () => { expect(newState[826].sections.traditionalpagetwo.enabled).toBeTruthy(); }); - it('should enable submission section properly', () => { + it('should disable submission section properly', () => { let action: SubmissionObjectAction = new EnableSectionAction(submissionId, 'traditionalpagetwo'); let newState = submissionObjectReducer(initState, action); @@ -306,8 +312,8 @@ describe('submissionReducer test suite', () => { authority: null, display: 'Author, Test', confidence: -1, - place: 0 - } + place: 0, + }, ], 'dc.title': [ { @@ -316,8 +322,8 @@ describe('submissionReducer test suite', () => { authority: null, display: 'Title Test', confidence: -1, - place: 0 - } + place: 0, + }, ], 'dc.date.issued': [ { @@ -326,9 +332,9 @@ describe('submissionReducer test suite', () => { authority: null, display: '2015', confidence: -1, - place: 0 - } - ] + place: 0, + }, + ], } as any; const action = new UpdateSectionDataAction(submissionId, 'traditionalpageone', data, [], []); @@ -352,8 +358,8 @@ describe('submissionReducer test suite', () => { const errors = [ { path: '/sections/license', - message: 'error.validation.license.notgranted' - } + message: 'error.validation.license.notgranted', + }, ]; const action = new UpdateSectionDataAction(submissionId, 'traditionalpageone', {}, errors, errors); @@ -374,7 +380,7 @@ describe('submissionReducer test suite', () => { it('should add submission section error properly', () => { const error = { path: '/sections/traditionalpageone/dc.title/0', - message: 'error.validation.traditionalpageone.required' + message: 'error.validation.traditionalpageone.required', }; const action = new InertSectionErrorsAction(submissionId, 'traditionalpageone', error); @@ -387,21 +393,21 @@ describe('submissionReducer test suite', () => { const errors = [ { path: '/sections/traditionalpageone/dc.contributor.author', - message: 'error.validation.required' + message: 'error.validation.required', }, { path: '/sections/traditionalpageone/dc.date.issued', - message: 'error.validation.required' - } + message: 'error.validation.required', + }, ]; const error = { path: '/sections/traditionalpageone/dc.contributor.author', - message: 'error.validation.required' + message: 'error.validation.required', }; const expectedErrors = [{ path: '/sections/traditionalpageone/dc.date.issued', - message: 'error.validation.required' + message: 'error.validation.required', }]; let action: any = new UpdateSectionDataAction(submissionId, 'traditionalpageone', {}, errors, errors); @@ -433,9 +439,9 @@ describe('submissionReducer test suite', () => { authority: null, display: '28297_389341539060_6452876_n.jpg', confidence: -1, - place: 0 - } - ] + place: 0, + }, + ], }, accessConditions: [], format: { @@ -446,17 +452,17 @@ describe('submissionReducer test suite', () => { supportLevel: 0, internal: false, extensions: null, - type: 'bitstreamformat' + type: 'bitstreamformat', }, sizeBytes: 22737, checkSum: { checkSumAlgorithm: 'MD5', - value: '8722864dd671912f94a999ac7c4949d2' + value: '8722864dd671912f94a999ac7c4949d2', }, - url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content' + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content', }; const expectedState = { - files: [fileData] + files: [fileData], }; const action = new NewUploadedFileAction(submissionId, 'upload', uuid, fileData); @@ -478,9 +484,9 @@ describe('submissionReducer test suite', () => { authority: null, display: 'image_test.jpg', confidence: -1, - place: 0 - } - ] + place: 0, + }, + ], }, accessConditions: [], format: { @@ -491,14 +497,14 @@ describe('submissionReducer test suite', () => { supportLevel: 0, internal: false, extensions: null, - type: 'bitstreamformat' + type: 'bitstreamformat', }, sizeBytes: 22737, checkSum: { checkSumAlgorithm: 'MD5', - value: '8722864dd671912f94a999ac7c4949d2' + value: '8722864dd671912f94a999ac7c4949d2', }, - url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content' + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content', }; const fileData2: any = { uuid: uuid2, @@ -510,9 +516,9 @@ describe('submissionReducer test suite', () => { authority: null, display: 'image_test.jpg', confidence: -1, - place: 0 - } - ] + place: 0, + }, + ], }, accessConditions: [], format: { @@ -523,14 +529,14 @@ describe('submissionReducer test suite', () => { supportLevel: 0, internal: false, extensions: null, - type: 'bitstreamformat' + type: 'bitstreamformat', }, sizeBytes: 22737, checkSum: { checkSumAlgorithm: 'MD5', - value: '8722864dd671912f94a999ac7c4949d2' + value: '8722864dd671912f94a999ac7c4949d2', }, - url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/7e2f4ba9-9316-41fd-844a-1ef435f41a42/content' + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/7e2f4ba9-9316-41fd-844a-1ef435f41a42/content', }; const state: SubmissionObjectState = Object.assign({}, initState, { @@ -538,15 +544,15 @@ describe('submissionReducer test suite', () => { sections: Object.assign({}, initState[submissionId].sections, { upload: Object.assign({}, initState[submissionId].sections.upload, { data: { - files: [fileData, fileData2] - } - }) - }) - }) + files: [fileData, fileData2], + }, + }), + }), + }), }); const expectedState = { - files: [fileData] + files: [fileData], }; const action = new DeleteUploadedFileAction(submissionId, 'upload', uuid2); @@ -567,9 +573,9 @@ describe('submissionReducer test suite', () => { authority: null, display: 'image_test.jpg', confidence: -1, - place: 0 - } - ] + place: 0, + }, + ], }, accessConditions: [], format: { @@ -580,14 +586,14 @@ describe('submissionReducer test suite', () => { supportLevel: 0, internal: false, extensions: null, - type: 'bitstreamformat' + type: 'bitstreamformat', }, sizeBytes: 22737, checkSum: { checkSumAlgorithm: 'MD5', - value: '8722864dd671912f94a999ac7c4949d2' + value: '8722864dd671912f94a999ac7c4949d2', }, - url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content' + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content', }; const fileData2: any = { uuid: uuid, @@ -599,9 +605,9 @@ describe('submissionReducer test suite', () => { authority: null, display: 'New title', confidence: -1, - place: 0 - } - ] + place: 0, + }, + ], }, accessConditions: [], format: { @@ -612,14 +618,14 @@ describe('submissionReducer test suite', () => { supportLevel: 0, internal: false, extensions: null, - type: 'bitstreamformat' + type: 'bitstreamformat', }, sizeBytes: 22737, checkSum: { checkSumAlgorithm: 'MD5', - value: '8722864dd671912f94a999ac7c4949d2' + value: '8722864dd671912f94a999ac7c4949d2', }, - url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/7e2f4ba9-9316-41fd-844a-1ef435f41a42/content' + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/7e2f4ba9-9316-41fd-844a-1ef435f41a42/content', }; const state: SubmissionObjectState = Object.assign({}, initState, { @@ -627,15 +633,15 @@ describe('submissionReducer test suite', () => { sections: Object.assign({}, initState[submissionId].sections, { upload: Object.assign({}, initState[submissionId].sections.upload, { data: { - files: [fileData] - } - }) - }) - }) + files: [fileData], + }, + }), + }), + }), }); const expectedState = { - files: [fileData2] + files: [fileData2], }; const action = new EditFileDataAction(submissionId, 'upload', uuid, fileData2); @@ -644,4 +650,20 @@ describe('submissionReducer test suite', () => { expect(newState[826].sections.upload.data).toEqual(expectedState); }); + it('should enable duplicates section properly', () => { + + let action: SubmissionObjectAction = new EnableSectionAction(submissionId, 'duplicates'); + let newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.duplicates.enabled).toBeTruthy(); + }); + + it('should clean duplicates section properly', () => { + + let action = new CleanDuplicateDetectionAction(submissionId); + let newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.duplicates.enabled).toBeFalsy(); + }); + }); diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index a05bf05f52c..a34cf236b94 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -1,11 +1,20 @@ -import { hasValue, isEmpty, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import differenceWith from 'lodash/differenceWith'; import findKey from 'lodash/findKey'; import isEqual from 'lodash/isEqual'; import uniqWith from 'lodash/uniqWith'; +import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; +import { + hasValue, + isEmpty, + isNotEmpty, + isNotNull, + isNull, + isUndefined, +} from '../../shared/empty.util'; import { ChangeSubmissionCollectionAction, + CleanDuplicateDetectionAction, CompleteInitSubmissionFormAction, DeleteSectionErrorsAction, DeleteUploadedFileAction, @@ -14,6 +23,7 @@ import { DepositSubmissionSuccessAction, DisableSectionAction, EditFileDataAction, + EditFilePrimaryBitstreamAction, EnableSectionAction, InertSectionErrorsAction, InitSectionAction, @@ -36,9 +46,8 @@ import { SetSectionFormId, SubmissionObjectAction, SubmissionObjectActionTypes, - UpdateSectionDataAction + UpdateSectionDataAction, } from './submission-objects.actions'; -import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; import { SubmissionSectionObject } from './submission-section-object.model'; /** @@ -203,6 +212,10 @@ export function submissionObjectReducer(state = initialState, action: Submission return newFile(state, action as NewUploadedFileAction); } + case SubmissionObjectActionTypes.EDIT_FILE_PRIMARY_BITSTREAM_DATA: { + return editPrimaryBitstream(state, action as EditFilePrimaryBitstreamAction); + } + case SubmissionObjectActionTypes.EDIT_FILE_DATA: { return editFileData(state, action as EditFileDataAction); } @@ -224,6 +237,10 @@ export function submissionObjectReducer(state = initialState, action: Submission return removeSectionErrors(state, action as RemoveSectionErrorsAction); } + case SubmissionObjectActionTypes.CLEAN_DUPLICATE_DETECTION: { + return cleanDuplicateDetectionSection(state, action as CleanDuplicateDetectionAction); + } + default: { return state; } @@ -249,10 +266,10 @@ const removeError = (state: SubmissionObjectState, action: DeleteSectionErrorsAc [ submissionId ]: Object.assign({}, state[ submissionId ], { sections: Object.assign({}, state[ submissionId ].sections, { [ sectionId ]: Object.assign({}, state[ submissionId ].sections [ sectionId ], { - errorsToShow: filteredErrors - }) - }) - }) + errorsToShow: filteredErrors, + }), + }), + }), }); } else { return state; @@ -269,10 +286,10 @@ const addError = (state: SubmissionObjectState, action: InertSectionErrorsAction [ submissionId ]: Object.assign({}, state[ submissionId ], { activeSection: state[ action.payload.submissionId ].activeSection, sections: Object.assign({}, state[ submissionId ].sections, { [ sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { - errorsToShow - }) + errorsToShow, + }), }), - }) + }), }); } else { return state; @@ -296,10 +313,10 @@ function removeSectionErrors(state: SubmissionObjectState, action: RemoveSection [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { sections: Object.assign({}, state[ action.payload.submissionId ].sections, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { - errorsToShow: [] - }) - }) - }) + errorsToShow: [], + }), + }), + }), }); } else { return state; @@ -349,8 +366,8 @@ function resetSubmission(state: SubmissionObjectState, action: ResetSubmissionFo return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { sections: Object.create(null), - isLoading: true - }) + isLoading: true, + }), }); } else { return state; @@ -371,8 +388,8 @@ function completeInit(state: SubmissionObjectState, action: CompleteInitSubmissi if (hasValue(state[ action.payload.submissionId ])) { return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { - isLoading: false - }) + isLoading: false, + }), }); } else { return state; @@ -391,7 +408,7 @@ function completeInit(state: SubmissionObjectState, action: CompleteInitSubmissi * the new state, with the flag set to true. */ function saveSubmission(state: SubmissionObjectState, - action: SaveSubmissionFormAction + action: SaveSubmissionFormAction | SaveSubmissionSectionFormAction | SaveForLaterSubmissionFormAction | SaveAndDepositSubmissionAction): SubmissionObjectState { @@ -402,7 +419,7 @@ function saveSubmission(state: SubmissionObjectState, sections: state[ action.payload.submissionId ].sections, isLoading: state[ action.payload.submissionId ].isLoading, savePending: true, - }) + }), }); } else { return state; @@ -422,7 +439,7 @@ function saveSubmission(state: SubmissionObjectState, * the new state, with the flag set to false. */ function completeSave(state: SubmissionObjectState, - action: SaveSubmissionFormSuccessAction + action: SaveSubmissionFormSuccessAction | SaveForLaterSubmissionFormSuccessAction | SaveSubmissionSectionFormSuccessAction | SaveSubmissionFormErrorAction @@ -432,7 +449,7 @@ function completeSave(state: SubmissionObjectState, return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { savePending: false, - }) + }), }); } else { return state; @@ -455,7 +472,7 @@ function startDeposit(state: SubmissionObjectState, action: DepositSubmissionAct [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { savePending: false, depositPending: true, - }) + }), }); } else { return state; @@ -477,7 +494,7 @@ function endDeposit(state: SubmissionObjectState, action: DepositSubmissionSucce return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { depositPending: false, - }) + }), }); } else { return state; @@ -497,8 +514,8 @@ function endDeposit(state: SubmissionObjectState, action: DepositSubmissionSucce function changeCollection(state: SubmissionObjectState, action: ChangeSubmissionCollectionAction): SubmissionObjectState { return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { - collection: action.payload.collectionId - }) + collection: action.payload.collectionId, + }), }); } @@ -522,7 +539,7 @@ function setActiveSection(state: SubmissionObjectState, action: SetActiveSection sections: state[ action.payload.submissionId ].sections, isLoading: state[ action.payload.submissionId ].isLoading, savePending: state[ action.payload.submissionId ].savePending, - }) + }), }); } else { return state; @@ -548,6 +565,7 @@ function initSection(state: SubmissionObjectState, action: InitSectionAction): S header: action.payload.header, config: action.payload.config, mandatory: action.payload.mandatory, + scope: action.payload.scope, sectionType: action.payload.sectionType, visibility: action.payload.visibility, collapsed: false, @@ -556,10 +574,10 @@ function initSection(state: SubmissionObjectState, action: InitSectionAction): S errorsToShow: [], serverValidationErrors: action.payload.errors || [], isLoading: false, - isValid: isEmpty(action.payload.errors) - } - }) - }) + isValid: isEmpty(action.payload.errors), + }, + }), + }), }); } else { return state; @@ -583,10 +601,10 @@ function setSectionFormId(state: SubmissionObjectState, action: SetSectionFormId sections: Object.assign({}, state[ action.payload.submissionId ].sections, { [ action.payload.sectionId ]: { ...state[ action.payload.submissionId ].sections [action.payload.sectionId], - formId: action.payload.formId - } - }) - }) + formId: action.payload.formId, + }, + }), + }), }); } else { return state; @@ -614,10 +632,10 @@ function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDa data: action.payload.data, errorsToShow: action.payload.errorsToShow, serverValidationErrors: action.payload.serverValidationErrors, - metadata: reduceSectionMetadata(action.payload.metadata, state[ action.payload.submissionId ].sections [ action.payload.sectionId ].metadata) - }) - }) - }) + metadata: reduceSectionMetadata(action.payload.metadata, state[ action.payload.submissionId ].sections [ action.payload.sectionId ].metadata), + }), + }), + }), }); } else { return state; @@ -661,10 +679,10 @@ function changeSectionState(state: SubmissionObjectState, action: EnableSectionA // sections: deleteProperty(state[ action.payload.submissionId ].sections, action.payload.sectionId), sections: Object.assign({}, state[ action.payload.submissionId ].sections, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { - enabled - }) - }) - }) + enabled, + }), + }), + }), }); } else { return state; @@ -688,11 +706,11 @@ function setIsValid(state: SubmissionObjectState, action: SectionStatusChangeAct sections: Object.assign({}, state[ action.payload.submissionId ].sections, Object.assign({}, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { - isValid: action.payload.status - }) - }) - ) - }) + isValid: action.payload.status, + }), + }), + ), + }), }); } else { return state; @@ -716,7 +734,7 @@ function newFile(state: SubmissionObjectState, action: NewUploadedFileAction): S let newData; if (isUndefined(filesData.files)) { newData = { - files: [action.payload.data] + files: [action.payload.data], }; } else { newData = filesData; @@ -728,13 +746,53 @@ function newFile(state: SubmissionObjectState, action: NewUploadedFileAction): S sections: Object.assign({}, state[ action.payload.submissionId ].sections, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { enabled: true, - data: newData - }) - }) - }) + data: newData, + }), + }), + }), }); } +/** + * Edit primary bitstream. + * + * @param state + * the current state + * @param action + * an EditFilePrimaryBitstreamAction action + * @return SubmissionObjectState + * the new state, with the edited file. + */ +function editPrimaryBitstream(state: SubmissionObjectState, action: EditFilePrimaryBitstreamAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + const { submissionId, sectionId, fileId } = action.payload; + + const fileIndex = findKey(filesData.files, { uuid: fileId }); + if (isNull(fileIndex)) { + return state; + } + + const submission = state[submissionId]; + return { + ...state, + [submissionId]: { + ...submission, + sections: { + ...submission.sections, + [sectionId]: { + ...submission.sections[sectionId], + data: { + ...submission.sections[sectionId].data as WorkspaceitemSectionUploadObject, + primary: fileId, + }, + }, + }, + isLoading: submission.isLoading, + savePending: submission.savePending, + }, + }; +} + /** * Edit a file. * @@ -761,14 +819,14 @@ function editFileData(state: SubmissionObjectState, action: EditFileDataAction): Object.assign({}, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { - files: newData - }) - }) - }) + files: newData, + }), + }), + }), ), isLoading: state[ action.payload.submissionId ].isLoading, savePending: state[ action.payload.submissionId ].savePending, - }) + }), }); } } @@ -790,7 +848,7 @@ function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileActi if (hasValue(filesData.files)) { const fileIndex: any = findKey( filesData.files, - {uuid: action.payload.fileId}); + { uuid: action.payload.fileId }); if (isNotNull(fileIndex)) { const newData = Array.from(filesData.files); newData.splice(fileIndex, 1); @@ -800,14 +858,31 @@ function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileActi Object.assign({}, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ], { data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { - files: newData - }) - }) - }) - ) - }) + files: newData, + }), + }), + }), + ), + }), }); } } return state; } + +function cleanDuplicateDetectionSection(state: SubmissionObjectState, action: CleanDuplicateDetectionAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) && state[action.payload.submissionId].sections.hasOwnProperty('duplicates')) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ 'duplicates' ]: Object.assign({}, state[ action.payload.submissionId ].sections.duplicates, { + enabled: false, + data: { potentialDuplicates: [] }, + }), + }), + }), + }); + } else { + return state; + } +} diff --git a/src/app/submission/objects/submission-section-object.model.ts b/src/app/submission/objects/submission-section-object.model.ts index 16e437da80f..5d8a14c7450 100644 --- a/src/app/submission/objects/submission-section-object.model.ts +++ b/src/app/submission/objects/submission-section-object.model.ts @@ -1,6 +1,9 @@ -import { SectionsType } from '../sections/sections-type'; -import { SectionVisibility } from './section-visibility.model'; import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; +import { SectionsType } from '../sections/sections-type'; +import { + SectionScope, + SectionVisibility, +} from './section-visibility.model'; import { SubmissionSectionError } from './submission-section-error.model'; /** @@ -22,6 +25,11 @@ export interface SubmissionSectionObject { */ mandatory: boolean; + /** + * The submission scope for this section + */ + scope: SectionScope; + /** * The section type */ diff --git a/src/app/submission/provide-submission-state.ts b/src/app/submission/provide-submission-state.ts new file mode 100644 index 00000000000..3656df6da3d --- /dev/null +++ b/src/app/submission/provide-submission-state.ts @@ -0,0 +1,28 @@ +import { + EnvironmentProviders, + importProvidersFrom, + makeEnvironmentProviders, +} from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { + Action, + StoreConfig, + StoreModule, +} from '@ngrx/store'; + +import { storeModuleConfig } from '../app.reducer'; +import { submissionEffects } from './submission.effects'; +import { + submissionReducers, + SubmissionState, +} from './submission.reducers'; + +export const provideSubmissionState = (): EnvironmentProviders => { + return makeEnvironmentProviders([ + importProvidersFrom( + StoreModule.forFeature('submission', submissionReducers, storeModuleConfig as StoreConfig), + EffectsModule.forFeature(submissionEffects), + ), + ]); +}; + diff --git a/src/app/submission/sections/accesses/section-accesses.component.spec.ts b/src/app/submission/sections/accesses/section-accesses.component.spec.ts index c70758a4df4..eb7d1cdaf04 100644 --- a/src/app/submission/sections/accesses/section-accesses.component.spec.ts +++ b/src/app/submission/sections/accesses/section-accesses.component.spec.ts @@ -1,45 +1,66 @@ -import { FormService } from '../../../shared/form/form.service'; -import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; - -import { SubmissionSectionAccessesComponent } from './section-accesses.component'; -import { SectionsService } from '../sections.service'; -import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; - -import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; -import { SubmissionAccessesConfigDataService } from '../../../core/config/submission-accesses-config-data.service'; -import { - getSubmissionAccessesConfigNotChangeDiscoverableService, - getSubmissionAccessesConfigService -} from '../../../shared/mocks/section-accesses-config.service.mock'; -import { SectionAccessesService } from './section-accesses.service'; -import { SectionFormOperationsService } from '../form/section-form-operations.service'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { - SubmissionJsonPatchOperationsService -} from '../../../core/submission/submission-json-patch-operations.service'; -import { getSectionAccessesService } from '../../../shared/mocks/section-accesses.service.mock'; -import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; -import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { CommonModule } from '@angular/common'; import { - SubmissionJsonPatchOperationsServiceStub -} from '../../../shared/testing/submission-json-patch-operations-service.stub'; -import { BrowserModule } from '@angular/platform-browser'; - -import { of as observableOf } from 'rxjs'; -import { Store } from '@ngrx/store'; -import { FormComponent } from '../../../shared/form/form.component'; + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { + DYNAMIC_FORM_CONTROL_MAP_FN, DynamicCheckboxModel, DynamicDatePickerModel, DynamicFormArrayModel, - DynamicSelectModel + DynamicSelectModel, } from '@ng-dynamic-forms/core'; -import { AppState } from '../../../app.reducer'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { + APP_CONFIG, + APP_DATA_SERVICES_MAP, +} from 'src/config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; + +import { SubmissionAccessesConfigDataService } from '../../../core/config/submission-accesses-config-data.service'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; +import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; +import { XSRFService } from '../../../core/xsrf/xsrf.service'; +import { dsDynamicFormControlMapFn } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; +import { DsDynamicTypeBindRelationService } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { LiveRegionService } from '../../../shared/live-region/live-region.service'; +import { getLiveRegionServiceStub } from '../../../shared/live-region/live-region.service.stub'; +import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; +import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; import { getMockFormService } from '../../../shared/mocks/form-service.mock'; +import { getSectionAccessesService } from '../../../shared/mocks/section-accesses.service.mock'; +import { + getSubmissionAccessesConfigNotChangeDiscoverableService, + getSubmissionAccessesConfigService, +} from '../../../shared/mocks/section-accesses-config.service.mock'; import { mockAccessesFormData } from '../../../shared/mocks/submission.mock'; -import { accessConditionChangeEvent, checkboxChangeEvent } from '../../../shared/testing/form-event.stub'; +import { + accessConditionChangeEvent, + checkboxChangeEvent, +} from '../../../shared/testing/form-event.stub'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service.stub'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionsService } from '../sections.service'; +import { SubmissionSectionAccessesComponent } from './section-accesses.component'; +import { SectionAccessesService } from './section-accesses.service'; + + +function getMockDsDynamicTypeBindRelationService(): DsDynamicTypeBindRelationService { + return jasmine.createSpyObj('DsDynamicTypeBindRelationService', { + getRelatedFormModel: jasmine.createSpy('getRelatedFormModel'), + matchesCondition: jasmine.createSpy('matchesCondition'), + subscribeRelations: jasmine.createSpy('subscribeRelations'), + }); +} describe('SubmissionSectionAccessesComponent', () => { let component: SubmissionSectionAccessesComponent; @@ -70,12 +91,12 @@ describe('SubmissionSectionAccessesComponent', () => { enabled: true, data: { discoverable: true, - accessConditions: [] + accessConditions: [], }, errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, }; describe('First with canChangeDiscoverable true', () => { @@ -83,29 +104,38 @@ describe('SubmissionSectionAccessesComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ - BrowserModule, - TranslateModule.forRoot() + CommonModule, + TranslateModule.forRoot(), + SubmissionSectionAccessesComponent, + FormComponent, ], - declarations: [SubmissionSectionAccessesComponent, FormComponent], providers: [ { provide: SectionsService, useValue: sectionsServiceStub }, { provide: SubmissionAccessesConfigDataService, useValue: submissionAccessesConfigService }, { provide: SectionAccessesService, useValue: sectionAccessesService }, { provide: SectionFormOperationsService, useValue: sectionFormOperationsService }, { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, - { provide: TranslateService, useValue: getMockTranslateService() }, { provide: FormService, useValue: getMockFormService() }, { provide: Store, useValue: storeStub }, { provide: SubmissionJsonPatchOperationsService, useValue: SubmissionJsonPatchOperationsServiceStub }, { provide: 'sectionDataProvider', useValue: sectionData }, { provide: 'submissionIdProvider', useValue: '1508' }, - FormBuilderService - ] + { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, + { provide: SubmissionObjectDataService, useValue: {} }, + { provide: SubmissionService, useValue: {} }, + { provide: XSRFService, useValue: {} }, + { provide: APP_CONFIG, useValue: environment }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub }, + FormBuilderService, + provideMockStore({}), + ], }) .compileComponents(); }); - beforeEach(inject([Store], (store: Store) => { + beforeEach(() => { fixture = TestBed.createComponent(SubmissionSectionAccessesComponent); component = fixture.componentInstance; formService = TestBed.inject(FormService); @@ -114,8 +144,7 @@ describe('SubmissionSectionAccessesComponent', () => { formService.isValid.and.returnValue(observableOf(true)); formService.getFormData.and.returnValue(observableOf(mockAccessesFormData)); fixture.detectChanges(); - })); - + }); it('should create', () => { expect(component).toBeTruthy(); @@ -144,8 +173,8 @@ describe('SubmissionSectionAccessesComponent', () => { }); it('should have set maxStartDate and maxEndDate properly', () => { - const maxStartDate = {year: 2024, month: 12, day: 20}; - const maxEndDate = {year: 2022, month: 6, day: 20}; + const maxStartDate = { year: 2024, month: 12, day: 20 }; + const maxEndDate = { year: 2022, month: 6, day: 20 }; const startDateModel = formbuilderService.findById('startDate', component.formModel); expect(startDateModel.max).toEqual(maxStartDate); @@ -169,42 +198,50 @@ describe('SubmissionSectionAccessesComponent', () => { describe('when canDescoverable is false', () => { - - beforeEach(async () => { + formService = getMockFormService(); await TestBed.configureTestingModule({ imports: [ - BrowserModule, - TranslateModule.forRoot() + CommonModule, + TranslateModule.forRoot(), + SubmissionSectionAccessesComponent, + FormComponent, ], - declarations: [SubmissionSectionAccessesComponent, FormComponent], providers: [ { provide: SectionsService, useValue: sectionsServiceStub }, - { provide: FormBuilderService, useValue: builderService }, { provide: SubmissionAccessesConfigDataService, useValue: getSubmissionAccessesConfigNotChangeDiscoverableService() }, { provide: SectionAccessesService, useValue: sectionAccessesService }, { provide: SectionFormOperationsService, useValue: sectionFormOperationsService }, { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, - { provide: TranslateService, useValue: getMockTranslateService() }, - { provide: FormService, useValue: getMockFormService() }, + { provide: FormService, useValue: formService }, { provide: Store, useValue: storeStub }, { provide: SubmissionJsonPatchOperationsService, useValue: SubmissionJsonPatchOperationsServiceStub }, { provide: 'sectionDataProvider', useValue: sectionData }, { provide: 'submissionIdProvider', useValue: '1508' }, - ] + { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, + { provide: SubmissionObjectDataService, useValue: {} }, + { provide: SubmissionService, useValue: {} }, + { provide: XSRFService, useValue: {} }, + { provide: APP_CONFIG, useValue: environment }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, + FormBuilderService, + provideMockStore({}), + + ], }) .compileComponents(); }); - beforeEach(inject([Store], (store: Store) => { + beforeEach(() => { fixture = TestBed.createComponent(SubmissionSectionAccessesComponent); component = fixture.componentInstance; - formService = TestBed.inject(FormService); formService.validateAllFormFields.and.callFake(() => null); formService.isValid.and.returnValue(observableOf(true)); formService.getFormData.and.returnValue(observableOf(mockAccessesFormData)); fixture.detectChanges(); - })); + }); it('should have formModel length should be 1', () => { diff --git a/src/app/submission/sections/accesses/section-accesses.component.ts b/src/app/submission/sections/accesses/section-accesses.component.ts index 451fc736704..b47f1cf8ff0 100644 --- a/src/app/submission/sections/accesses/section-accesses.component.ts +++ b/src/app/submission/sections/accesses/section-accesses.component.ts @@ -1,16 +1,10 @@ -import { SectionAccessesService } from './section-accesses.service'; -import { Component, Inject, ViewChild } from '@angular/core'; +import { NgIf } from '@angular/common'; +import { + Component, + Inject, + ViewChild, +} from '@angular/core'; import { UntypedFormControl } from '@angular/forms'; - -import { filter, map, mergeMap, take } from 'rxjs/operators'; -import { combineLatest, Observable, of, Subscription } from 'rxjs'; -import { TranslateService } from '@ngx-translate/core'; - -import { renderSectionFor } from '../sections-decorator'; -import { SectionsType } from '../sections-type'; -import { SectionDataObject } from '../models/section-data.model'; -import { SectionsService } from '../sections.service'; -import { SectionModelComponent } from '../models/section.model'; import { DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX, DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER, @@ -22,10 +16,44 @@ import { DynamicFormGroupModel, DynamicSelectModel, MATCH_ENABLED, - OR_OPERATOR + OR_OPERATOR, } from '@ng-dynamic-forms/core'; +import { DynamicDateControlValue } from '@ng-dynamic-forms/core/lib/model/dynamic-date-control.model'; +import { DynamicFormControlCondition } from '@ng-dynamic-forms/core/lib/model/misc/dynamic-form-control-relation.model'; +import { TranslateService } from '@ngx-translate/core'; +import { + combineLatest, + Observable, + of, + Subscription, +} from 'rxjs'; +import { + filter, + map, + mergeMap, + take, +} from 'rxjs/operators'; +import { AccessesConditionOption } from '../../../core/config/models/config-accesses-conditions-options.model'; +import { SubmissionAccessesConfigDataService } from '../../../core/config/submission-accesses-config-data.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { WorkspaceitemSectionAccessesObject } from '../../../core/submission/models/workspaceitem-section-accesses.model'; +import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; +import { dateToISOFormat } from '../../../shared/date.util'; +import { + hasValue, + isNotEmpty, + isNotNull, +} from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; import { ACCESS_CONDITION_GROUP_CONFIG, ACCESS_CONDITION_GROUP_LAYOUT, @@ -38,26 +66,9 @@ import { FORM_ACCESS_CONDITION_START_DATE_CONFIG, FORM_ACCESS_CONDITION_START_DATE_LAYOUT, FORM_ACCESS_CONDITION_TYPE_CONFIG, - FORM_ACCESS_CONDITION_TYPE_LAYOUT + FORM_ACCESS_CONDITION_TYPE_LAYOUT, } from './section-accesses.model'; -import { hasValue, isNotEmpty, isNotNull } from '../../../shared/empty.util'; -import { - WorkspaceitemSectionAccessesObject -} from '../../../core/submission/models/workspaceitem-section-accesses.model'; -import { SubmissionAccessesConfigDataService } from '../../../core/config/submission-accesses-config-data.service'; -import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; -import { FormComponent } from '../../../shared/form/form.component'; -import { FormService } from '../../../shared/form/form.service'; -import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { SectionFormOperationsService } from '../form/section-form-operations.service'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { AccessesConditionOption } from '../../../core/config/models/config-accesses-conditions-options.model'; -import { - SubmissionJsonPatchOperationsService -} from '../../../core/submission/submission-json-patch-operations.service'; -import { dateToISOFormat } from '../../../shared/date.util'; -import { DynamicFormControlCondition } from '@ng-dynamic-forms/core/lib/model/misc/dynamic-form-control-relation.model'; -import { DynamicDateControlValue } from '@ng-dynamic-forms/core/lib/model/dynamic-date-control.model'; +import { SectionAccessesService } from './section-accesses.service'; /** * This component represents a section for managing item's access conditions. @@ -65,9 +76,13 @@ import { DynamicDateControlValue } from '@ng-dynamic-forms/core/lib/model/dynami @Component({ selector: 'ds-section-accesses', templateUrl: './section-accesses.component.html', - styleUrls: ['./section-accesses.component.scss'] + styleUrls: ['./section-accesses.component.scss'], + imports: [ + FormComponent, + NgIf, + ], + standalone: true, }) -@renderSectionFor(SectionsType.AccessesCondition) export class SubmissionSectionAccessesComponent extends SectionModelComponent { /** @@ -163,7 +178,7 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { metadataModel.value = { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, - day: date.getUTCDate() + day: date.getUTCDate(), }; } else { metadataModel.value = accessCondition[key]; @@ -203,7 +218,7 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { take(1), filter((isValid) => isValid), mergeMap(() => this.formService.getFormData(this.formId)), - take(1) + take(1), ).subscribe((formData: any) => { const accessConditionsToSave = []; formData.accessCondition @@ -307,10 +322,10 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { const discoverableCheckboxConfig = Object.assign({}, ACCESS_FORM_CHECKBOX_CONFIG, { label: this.translate.instant('submission.sections.accesses.form.discoverable-label'), hint: this.translate.instant('submission.sections.accesses.form.discoverable-description'), - value: this.accessesData.discoverable + value: this.accessesData.discoverable, }); formModel.push( - new DynamicCheckboxModel(discoverableCheckboxConfig, ACCESS_FORM_CHECKBOX_LAYOUT) + new DynamicCheckboxModel(discoverableCheckboxConfig, ACCESS_FORM_CHECKBOX_LAYOUT), ); } @@ -322,8 +337,8 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { accessConditionTypeOptions.push( { label: accessCondition.name, - value: accessCondition.name - } + value: accessCondition.name, + }, ); } accessConditionTypeModelConfig.options = accessConditionTypeOptions; @@ -342,7 +357,7 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { maxStartDate = { year: min.getUTCFullYear(), month: min.getUTCMonth() + 1, - day: min.getUTCDate() + day: min.getUTCDate(), }; } } @@ -353,7 +368,7 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { maxEndDate = { year: max.getUTCFullYear(), month: max.getUTCMonth() + 1, - day: max.getUTCDate() + day: max.getUTCDate(), }; } } @@ -391,7 +406,7 @@ export class SubmissionSectionAccessesComponent extends SectionModelComponent { // Number of access conditions blocks in form accessConditionsArrayConfig.initialCount = isNotEmpty(this.accessesData.accessConditions) ? this.accessesData.accessConditions.length : 1; formModel.push( - new DynamicFormArrayModel(accessConditionsArrayConfig, ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT) + new DynamicFormArrayModel(accessConditionsArrayConfig, ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT), ); this.initModelData(formModel); diff --git a/src/app/submission/sections/accesses/section-accesses.model.ts b/src/app/submission/sections/accesses/section-accesses.model.ts index 435c521175a..6b4732b881d 100644 --- a/src/app/submission/sections/accesses/section-accesses.model.ts +++ b/src/app/submission/sections/accesses/section-accesses.model.ts @@ -12,7 +12,7 @@ import { DynamicCheckboxModelConfig } from '@ng-dynamic-forms/core/lib/model/che export const ACCESS_FORM_CHECKBOX_CONFIG: DynamicCheckboxModelConfig = { id: 'discoverable', - name: 'discoverable' + name: 'discoverable', }; export const ACCESS_FORM_CHECKBOX_LAYOUT = { @@ -20,21 +20,21 @@ export const ACCESS_FORM_CHECKBOX_LAYOUT = { element: { container: 'custom-control custom-checkbox pl-1', control: 'custom-control-input', - label: 'custom-control-label pt-1' - } + label: 'custom-control-label pt-1', + }, }; export const ACCESS_CONDITION_GROUP_CONFIG: DynamicFormGroupModelConfig = { id: 'accessConditionGroup', - group: [] + group: [], }; export const ACCESS_CONDITION_GROUP_LAYOUT: DynamicFormControlLayout = { element: { host: 'form-group access-condition-group col', container: 'pl-1 pr-1', - control: 'form-row ' - } + control: 'form-row ', + }, }; export const ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = { @@ -44,20 +44,20 @@ export const ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = export const ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = { grid: { group: 'form-row pt-4', - } + }, }; export const FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConfig = { id: 'name', label: 'submission.sections.accesses.form.access-condition-label', hint: 'submission.sections.accesses.form.access-condition-hint', - options: [] + options: [], }; export const FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = { element: { host: 'col-12', - label: 'col-form-label name-label' - } + label: 'col-form-label name-label', + }, }; export const FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePickerModelConfig = { @@ -71,24 +71,24 @@ export const FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePickerModelConf { match: MATCH_ENABLED, operator: OR_OPERATOR, - when: [] - } + when: [], + }, ], required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'submission.sections.accesses.form.date-required-from' - } + required: 'submission.sections.accesses.form.date-required-from', + }, }; export const FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { element: { - label: 'col-form-label' + label: 'col-form-label', }, grid: { - host: 'col-6' - } + host: 'col-6', + }, }; export const FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerModelConfig = { @@ -102,22 +102,22 @@ export const FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerModelConfig { match: MATCH_ENABLED, operator: OR_OPERATOR, - when: [] - } + when: [], + }, ], required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'submission.sections.accesses.form.date-required-until' - } + required: 'submission.sections.accesses.form.date-required-until', + }, }; export const FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { element: { - label: 'col-form-label' + label: 'col-form-label', }, grid: { - host: 'col-6' - } + host: 'col-6', + }, }; diff --git a/src/app/submission/sections/accesses/section-accesses.service.ts b/src/app/submission/sections/accesses/section-accesses.service.ts index f3585821362..4fa3a632d67 100644 --- a/src/app/submission/sections/accesses/section-accesses.service.ts +++ b/src/app/submission/sections/accesses/section-accesses.service.ts @@ -1,18 +1,20 @@ import { Injectable } from '@angular/core'; - -import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter } from 'rxjs/operators'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + filter, +} from 'rxjs/operators'; -import { SubmissionState } from '../../submission.reducers'; +import { WorkspaceitemSectionAccessesObject } from '../../../core/submission/models/workspaceitem-section-accesses.model'; import { isNotUndefined } from '../../../shared/empty.util'; import { submissionSectionDataFromIdSelector } from '../../selectors'; -import { WorkspaceitemSectionAccessesObject } from '../../../core/submission/models/workspaceitem-section-accesses.model'; +import { SubmissionState } from '../../submission.reducers'; /** * A service that provides methods to handle submission item's accesses condition state. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class SectionAccessesService { /** diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html index 0796da5a64a..1197df6332c 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html @@ -1,38 +1,39 @@ -
- - - - - - - - {{ getSelectedCcLicense().name }} - - - - {{ 'submission.sections.ccLicense.change' | translate }} - - - {{ 'submission.sections.ccLicense.select' | translate }} - - - - - - - - - - -
+@if (submissionCcLicenses) { +
+
+ + +
+
+} @@ -118,27 +119,26 @@
- - -
- + +
+ +
+
+
+ {{ 'submission.sections.ccLicense.link' | translate }}
-
-
- {{ 'submission.sections.ccLicense.link' | translate }} -
- - {{ licenseLink }} - -
-
- - {{ 'submission.sections.ccLicense.confirmation' | translate }} -
+ + {{ licenseLink }} + +
+
+ + {{ 'submission.sections.ccLicense.confirmation' | translate }}
- +
diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss index 62a902b79a3..142cd82822a 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss @@ -1,3 +1,13 @@ .options-select-menu { max-height: 25vh; } + +.ccLicense-select { + width: fit-content; +} + +.scrollable-menu { + height: auto; + max-height: var(--ds-dropdown-menu-max-height); + overflow-x: hidden; +} diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index bb83e48d212..2e82948c149 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -1,23 +1,29 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { SubmissionSectionCcLicensesComponent } from './submission-section-cc-licenses.component'; -import { SUBMISSION_CC_LICENSE } from '../../../core/submission/models/submission-cc-licence.resource-type'; -import { of as observableOf } from 'rxjs'; -import { SubmissionCcLicenseDataService } from '../../../core/submission/submission-cc-license-data.service'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { SharedModule } from '../../../shared/shared.module'; -import { SectionsService } from '../sections.service'; -import { SectionDataObject } from '../models/section-data.model'; -import { SectionsType } from '../sections-type'; import { TranslateModule } from '@ngx-translate/core'; -import { SubmissionCcLicence } from '../../../core/submission/models/submission-cc-license.model'; import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { FormBuilderService } from 'src/app/shared/form/builder/form-builder.service'; + +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; +import { SUBMISSION_CC_LICENSE } from '../../../core/submission/models/submission-cc-licence.resource-type'; +import { SubmissionCcLicence } from '../../../core/submission/models/submission-cc-license.model'; +import { SubmissionCcLicenseDataService } from '../../../core/submission/submission-cc-license-data.service'; import { SubmissionCcLicenseUrlDataService } from '../../../core/submission/submission-cc-license-url-data.service'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; -import {ConfigurationDataService} from '../../../core/data/configuration-data.service'; -import {ConfigurationProperty} from '../../../core/shared/configuration-property.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SectionsType } from '../sections-type'; +import { SubmissionSectionCcLicensesComponent } from './submission-section-cc-licenses.component'; describe('SubmissionSectionCcLicensesComponent', () => { @@ -33,7 +39,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { serverValidationErrors: [], header: 'test header', id: 'test section id', - sectionType: SectionsType.SubmissionForm + sectionType: SectionsType.SubmissionForm, }; const submissionCcLicenses: SubmissionCcLicence[] = [ @@ -96,12 +102,12 @@ describe('SubmissionSectionCcLicensesComponent', () => { { id: 'test enum id 2a I', label: 'test enum label 2a I', - description: 'test enum description 2a I' + description: 'test enum description 2a I', }, { id: 'test enum id 2a II', label: 'test enum label 2a II', - description: 'test enum description 2a II' + description: 'test enum description 2a II', }, ], }, @@ -113,12 +119,12 @@ describe('SubmissionSectionCcLicensesComponent', () => { { id: 'test enum id 2b I', label: 'test enum label 2b I', - description: 'test enum description 2b I' + description: 'test enum description 2b I', }, { id: 'test enum id 2b II', label: 'test enum label 2b II', - description: 'test enum description 2b II' + description: 'test enum description 2b II', }, ], }, @@ -139,7 +145,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { getCcLicenseLink: createSuccessfulRemoteDataObject$( { url: 'test cc license link', - } + }, ), }); @@ -150,7 +156,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { setSectionStatus: () => undefined, updateSectionData: (submissionId, sectionId, updatedData) => { component.sectionData.data = updatedData; - } + }, }; const operationsBuilder = jasmine.createSpyObj('operationsBuilder', { @@ -169,10 +175,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - SharedModule, TranslateModule.forRoot(), - ], - declarations: [ SubmissionSectionCcLicensesComponent, ], providers: [ @@ -184,8 +187,16 @@ describe('SubmissionSectionCcLicensesComponent', () => { { provide: 'collectionIdProvider', useValue: 'test collection id' }, { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, { provide: 'submissionIdProvider', useValue: 'test submission id' }, + { provide: FormBuilderService, useValue: {} }, ], }) + .overrideComponent(SubmissionSectionCcLicensesComponent, { + remove: { + imports:[ + ThemedLoadingComponent, + ], + }, + }) .compileComponents(); })); @@ -198,10 +209,10 @@ describe('SubmissionSectionCcLicensesComponent', () => { it('should display a dropdown with the different cc licenses', () => { expect( - de.query(By.css('.ccLicense-select ds-select .dropdown-menu button:nth-child(1)')).nativeElement.innerText + de.query(By.css('.ccLicense-select .scrollable-menu button:nth-child(1)')).nativeElement.innerText, ).toContain('test license name 1'); expect( - de.query(By.css('.ccLicense-select ds-select .dropdown-menu button:nth-child(2)')).nativeElement.innerText + de.query(By.css('.ccLicense-select .scrollable-menu button:nth-child(2)')).nativeElement.innerText, ).toContain('test license name 2'); }); @@ -215,9 +226,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { }); it('should display the selected cc license', () => { - expect( - de.query(By.css('.ccLicense-select ds-select button.selection')).nativeElement.innerText - ).toContain('test license name 2'); + expect(component.selectedCcLicense.name).toContain('test license name 2'); }); it('should display all field labels of the selected cc license only', () => { @@ -249,7 +258,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { new Map([ [ccLicence.fields[0], ccLicence.fields[0].enums[1]], [ccLicence.fields[1], ccLicence.fields[1].enums[0]], - ]) + ]), ); }); diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index c8cd898f966..91d07f1aa32 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -1,25 +1,67 @@ -import { Component, Inject } from '@angular/core'; -import { Observable, of as observableOf, Subscription } from 'rxjs'; -import { Field, Option, SubmissionCcLicence } from '../../../core/submission/models/submission-cc-license.model'; +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Inject, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + NgbDropdownModule, + NgbModal, + NgbModalRef, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + take, + tap, +} from 'rxjs/operators'; + +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getRemoteDataPayload + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload, } from '../../../core/shared/operators'; -import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { + Field, + Option, + SubmissionCcLicence, +} from '../../../core/submission/models/submission-cc-license.model'; +import { WorkspaceitemSectionCcLicenseObject } from '../../../core/submission/models/workspaceitem-section-cc-license.model'; import { SubmissionCcLicenseDataService } from '../../../core/submission/submission-cc-license-data.service'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { renderSectionFor } from '../sections-decorator'; -import { SectionsType } from '../sections-type'; +import { SubmissionCcLicenseUrlDataService } from '../../../core/submission/submission-cc-license-url-data.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; +import { DsSelectComponent } from '../../../shared/ds-select/ds-select.component'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; +import { VarDirective } from '../../../shared/utils/var.directive'; import { SectionModelComponent } from '../models/section.model'; import { SectionDataObject } from '../models/section-data.model'; import { SectionsService } from '../sections.service'; -import { WorkspaceitemSectionCcLicenseObject } from '../../../core/submission/models/workspaceitem-section-cc-license.model'; -import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { SubmissionCcLicenseUrlDataService } from '../../../core/submission/submission-cc-license-url-data.service'; -import {ConfigurationDataService} from '../../../core/data/configuration-data.service'; +import { SectionsType } from '../sections-type'; /** * This component represents the submission section to select the Creative Commons license. @@ -27,10 +69,23 @@ import {ConfigurationDataService} from '../../../core/data/configuration-data.se @Component({ selector: 'ds-submission-section-cc-licenses', templateUrl: './submission-section-cc-licenses.component.html', - styleUrls: ['./submission-section-cc-licenses.component.scss'] + styleUrls: ['./submission-section-cc-licenses.component.scss'], + imports: [ + TranslateModule, + NgIf, + ThemedLoadingComponent, + AsyncPipe, + VarDirective, + NgForOf, + DsSelectComponent, + NgbDropdownModule, + FormsModule, + InfiniteScrollModule, + BtnDisabledDirective, + ], + standalone: true, }) -@renderSectionFor(SectionsType.CcLicense) -export class SubmissionSectionCcLicensesComponent extends SectionModelComponent { +export class SubmissionSectionCcLicensesComponent extends SectionModelComponent implements OnChanges, OnInit { /** * The form id @@ -58,7 +113,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent /** * Cache of the available Creative Commons licenses. */ - submissionCcLicenses: SubmissionCcLicence[]; + submissionCcLicenses: SubmissionCcLicence[] = []; /** * Reference to NgbModal @@ -70,6 +125,25 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent */ defaultJurisdiction: string; + /** + * The currently selected cc licence + */ + selectedCcLicense: SubmissionCcLicence = new SubmissionCcLicence(); + + /** + * Options for paginated data loading + */ + ccLicenceOptions: FindListOptions = { + elementsPerPage: 20, + currentPage: 1, + }; + /** + * Check to stop paginated search + * + * @private + */ + private _isLastPage: boolean; + /** * The Creative Commons link saved in the workspace item. */ @@ -87,6 +161,8 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent return this.data.accepted; } + ccLicenseLink$: Observable; + constructor( protected modalService: NgbModal, protected sectionService: SectionsService, @@ -94,9 +170,10 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent protected submissionCcLicenseUrlDataService: SubmissionCcLicenseUrlDataService, protected operationsBuilder: JsonPatchOperationsBuilder, protected configService: ConfigurationDataService, + protected ref: ChangeDetectorRef, @Inject('collectionIdProvider') public injectedCollectionId: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, - @Inject('submissionIdProvider') public injectedSubmissionId: string + @Inject('submissionIdProvider') public injectedSubmissionId: string, ) { super( injectedCollectionId, @@ -105,6 +182,19 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent ); } + ngOnInit(): void { + super.ngOnInit(); + if (hasNoValue(this.ccLicenseLink$)) { + this.ccLicenseLink$ = this.getCcLicenseLink$(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (hasValue(changes.sectionData) || hasValue(changes.submissionCcLicenses)) { + this.ccLicenseLink$ = this.getCcLicenseLink$(); + } + } + /** * The data of this section. */ @@ -117,9 +207,10 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent * @param ccLicense the Creative Commons license to select. */ selectCcLicense(ccLicense: SubmissionCcLicence) { - if (!!this.getSelectedCcLicense() && this.getSelectedCcLicense().id === ccLicense.id) { + if (this.selectedCcLicense.id === ccLicense.id) { return; } + this.selectedCcLicense = ccLicense; this.setAccepted(false); this.updateSectionData({ ccLicense: { @@ -128,6 +219,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent }, uri: undefined, }); + this.ccLicenseLink$ = this.getCcLicenseLink$(); } /** @@ -154,11 +246,12 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent ccLicense: { id: ccLicense.id, fields: Object.assign({}, this.data.ccLicense.fields, { - [field.id]: option + [field.id]: option, }), }, accepted: false, }); + this.ccLicenseLink$ = this.getCcLicenseLink$(); } /** @@ -188,7 +281,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent */ getCcLicenseLink$(): Observable { - if (!!this.storedCcLicenseLink) { + if (this.storedCcLicenseLink) { return observableOf(this.storedCcLicenseLink); } if (!this.getSelectedCcLicense() || this.getSelectedCcLicense().fields.some( @@ -199,7 +292,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent return this.submissionCcLicenseUrlDataService.getCcLicenseLink( selectedCcLicense, new Map(selectedCcLicense.fields.map( - (field) => [field, this.getSelectedOption(selectedCcLicense, field)] + (field) => [field, this.getSelectedOption(selectedCcLicense, field)], )), ); } @@ -257,31 +350,25 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent ).subscribe((link) => { this.operationsBuilder.add(path, link.toString(), false, true); }); - } else if (!!this.data.uri) { + } else if (this.data.uri) { this.operationsBuilder.remove(path); } } this.sectionData.data = data; }), - this.submissionCcLicensesDataService.findAll({ elementsPerPage: 9999 }).pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((list) => list.page), - ).subscribe( - (licenses) => this.submissionCcLicenses = licenses - ), this.configService.findByPropertyName('cc.license.jurisdiction').pipe( getFirstCompletedRemoteData(), - getRemoteDataPayload() + getRemoteDataPayload(), ).subscribe((remoteData) => { - if (remoteData === undefined || remoteData.values.length === 0) { - // No value configured, use blank value (International jurisdiction) - this.defaultJurisdiction = ''; - } else { - this.defaultJurisdiction = remoteData.values[0]; - } - }) + if (remoteData === undefined || remoteData.values.length === 0) { + // No value configured, use blank value (International jurisdiction) + this.defaultJurisdiction = ''; + } else { + this.defaultJurisdiction = remoteData.values[0]; + } + }), ); + this.loadCcLicences(); } /** @@ -290,7 +377,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent */ setAccepted(accepted: boolean) { this.updateSectionData({ - accepted + accepted, }); this.updateSectionStatus(); } @@ -301,4 +388,31 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent updateSectionData(data: WorkspaceitemSectionCcLicenseObject) { this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, Object.assign({}, this.data, data)); } + + onScroll(event) { + if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) { + if (!this.isLoading && !this._isLastPage) { + this.ccLicenceOptions.currentPage++; + this.loadCcLicences(); + } + } + } + + loadCcLicences() { + this.isLoading = true; + + this.subscriptions.push( + this.submissionCcLicensesDataService.findAll(this.ccLicenceOptions).pipe( + getFirstSucceededRemoteDataPayload(), + tap((response) => this._isLastPage = response.pageInfo.currentPage === response.pageInfo.totalPages), + map((list) => list.page), + ).subscribe( + (licenses) => { + this.submissionCcLicenses = [...this.submissionCcLicenses, ...licenses]; + this.isLoading = false; + this.ref.detectChanges(); + }, + ), + ); + } } diff --git a/src/app/submission/sections/container/section-container.component.html b/src/app/submission/sections/container/section-container.component.html index e6ae9d1b9c1..3f0050ea51a 100644 --- a/src/app/submission/sections/container/section-container.component.html +++ b/src/app/submission/sections/container/section-container.component.html @@ -9,7 +9,7 @@ 'submission.sections.'+sectionData.header | translate }}
- @@ -48,4 +48,4 @@ -
\ No newline at end of file +
diff --git a/src/app/submission/sections/container/section-container.component.spec.ts b/src/app/submission/sections/container/section-container.component.spec.ts index d3f4a93762a..4dfa65a13bf 100644 --- a/src/app/submission/sections/container/section-container.component.spec.ts +++ b/src/app/submission/sections/container/section-container.component.spec.ts @@ -1,35 +1,45 @@ // Load the implementations that should be tested -import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync, } from '@angular/core/testing'; +import { + Component, + CUSTOM_ELEMENTS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; - -import { of as observableOf } from 'rxjs'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; -import { SubmissionSectionContainerComponent } from './section-container.component'; +import { + mockSubmissionCollectionId, + mockSubmissionId, +} from '../../../shared/mocks/submission.mock'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; import { createTestComponent } from '../../../shared/testing/utils.test'; -import { SectionsType } from '../sections-type'; -import { SectionsDirective } from '../sections.directive'; import { SubmissionService } from '../../submission.service'; -import { SectionsService } from '../sections.service'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; import { SectionDataObject } from '../models/section-data.model'; -import { mockSubmissionCollectionId, mockSubmissionId } from '../../../shared/mocks/submission.mock'; +import { SectionsDirective } from '../sections.directive'; +import { SectionsService } from '../sections.service'; +import { SectionsType } from '../sections-type'; +import { SubmissionSectionContainerComponent } from './section-container.component'; const sectionState = { header: 'submit.progressbar.describe.stepone', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', - mandatory: true, - sectionType: SectionsType.SubmissionForm, - collapsed: false, - enabled: true, - data: {}, - errorsToShow: [], - serverValidationErrors: [], - isLoading: false, - isValid: false + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + mandatory: true, + sectionType: SectionsType.SubmissionForm, + collapsed: false, + enabled: true, + data: {}, + errorsToShow: [], + serverValidationErrors: [], + isLoading: false, + isValid: false, } as any; const sectionObject: SectionDataObject = { @@ -40,7 +50,7 @@ const sectionObject: SectionDataObject = { serverValidationErrors: [], header: 'submit.progressbar.describe.stepone', id: 'traditionalpageone', - sectionType: SectionsType.SubmissionForm + sectionType: SectionsType.SubmissionForm, }; describe('SubmissionSectionContainerComponent test suite', () => { @@ -68,19 +78,17 @@ describe('SubmissionSectionContainerComponent test suite', () => { TestBed.configureTestingModule({ imports: [ NgbModule, - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), SubmissionSectionContainerComponent, SectionsDirective, TestComponent, - ], // declare the test component + ], providers: [ { provide: SectionsService, useValue: sectionsServiceStub }, { provide: SubmissionService, useValue: submissionServiceStub }, - SubmissionSectionContainerComponent + SubmissionSectionContainerComponent, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); })); @@ -226,7 +234,9 @@ describe('SubmissionSectionContainerComponent test suite', () => { @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: '', - template: `` + template: ``, + standalone: true, + imports: [NgbModule], }) class TestComponent { diff --git a/src/app/submission/sections/container/section-container.component.ts b/src/app/submission/sections/container/section-container.component.ts index 3331629f332..6f4126a1739 100644 --- a/src/app/submission/sections/container/section-container.component.ts +++ b/src/app/submission/sections/container/section-container.component.ts @@ -1,9 +1,25 @@ -import { Component, Injector, Input, OnInit, ViewChild } from '@angular/core'; +import { + AsyncPipe, + NgClass, + NgComponentOutlet, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Injector, + Input, + OnInit, + ViewChild, +} from '@angular/core'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; -import { SectionsDirective } from '../sections.directive'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { AlertType } from '../../../shared/alert/alert-type'; import { SectionDataObject } from '../models/section-data.model'; +import { SectionsDirective } from '../sections.directive'; import { rendersSectionType } from '../sections-decorator'; -import { AlertType } from '../../../shared/alert/alert-type'; /** * This component represents a section that contains the submission license form. @@ -11,7 +27,19 @@ import { AlertType } from '../../../shared/alert/alert-type'; @Component({ selector: 'ds-submission-section-container', templateUrl: './section-container.component.html', - styleUrls: ['./section-container.component.scss'] + styleUrls: ['./section-container.component.scss'], + imports: [ + AlertComponent, + NgForOf, + NgbAccordionModule, + NgComponentOutlet, + TranslateModule, + NgClass, + NgIf, + AsyncPipe, + SectionsDirective, + ], + standalone: true, }) export class SubmissionSectionContainerComponent implements OnInit { @@ -68,7 +96,7 @@ export class SubmissionSectionContainerComponent implements OnInit { { provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: [] }, { provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: [] }, ], - parent: this.injector + parent: this.injector, }); } @@ -87,7 +115,7 @@ export class SubmissionSectionContainerComponent implements OnInit { /** * Find the correct component based on the section's type */ - getSectionContent(): string { + getSectionContent() { return rendersSectionType(this.sectionData.sectionType); } } diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.html b/src/app/submission/sections/duplicates/section-duplicates.component.html new file mode 100644 index 00000000000..d9e33a70f7e --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.html @@ -0,0 +1,20 @@ + +
+ +
{{ 'submission.sections.duplicates.none' | translate }}
+
+ +
{{ 'submission.sections.duplicates.detected' | translate }}
+
+ {{dupe.title}} +
+ {{('item.preview.' + metadatum.key) | translate}} {{metadatum.value}} +
+

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

+

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

+
+
+
diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts new file mode 100644 index 00000000000..501a60e3b82 --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts @@ -0,0 +1,266 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { NgxPaginationModule } from 'ngx-pagination'; +import { of as observableOf } from 'rxjs'; + +import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { License } from '../../../core/shared/license.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormService } from '../../../shared/form/form.service'; +import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; +import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; +import { getMockFormService } from '../../../shared/mocks/form-service.mock'; +import { + mockSubmissionCollectionId, + mockSubmissionId, +} from '../../../shared/mocks/submission.mock'; +import { defaultUUID } from '../../../shared/mocks/uuid.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { Duplicate } from '../../../shared/object-list/duplicate-data/duplicate.model'; +import { DUPLICATE } from '../../../shared/object-list/duplicate-data/duplicate.resource-type'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionsService } from '../sections.service'; +import { SectionsType } from '../sections-type'; +import { SubmissionSectionDuplicatesComponent } from './section-duplicates.component'; + +function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService { + return jasmine.createSpyObj('FormOperationsService', { + getConfigAll: jasmine.createSpy('getConfigAll'), + getConfigByHref: jasmine.createSpy('getConfigByHref'), + getConfigByName: jasmine.createSpy('getConfigByName'), + getConfigBySearch: jasmine.createSpy('getConfigBySearch'), + }); +} + +function getMockCollectionDataService(): CollectionDataService { + return jasmine.createSpyObj('CollectionDataService', { + findById: jasmine.createSpy('findById'), + findByHref: jasmine.createSpy('findByHref'), + }); +} + +const duplicates: Duplicate[] = [{ + title: 'Unique title', + uuid: defaultUUID, + workflowItemId: 1, + workspaceItemId: 2, + owningCollection: 'Test Collection', + metadata: { + 'dc.title': [ + Object.assign(new MetadataValue(), { + 'value': 'Unique title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + })], + }, + type: DUPLICATE, + _links: { + self: { + href: 'http://localhost:8080/server/api/core/submission/duplicates/search?uuid=testid', + }, + }, +}]; + +const sectionObject = { + header: 'submission.sections.submit.progressbar.duplicates', + mandatory: true, + opened: true, + data: { potentialDuplicates: duplicates }, + errorsToShow: [], + serverValidationErrors: [], + id: 'duplicates', + sectionType: SectionsType.Duplicates, + sectionVisibility: null, +}; + +describe('SubmissionSectionDuplicatesComponent test suite', () => { + let comp: SubmissionSectionDuplicatesComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: any = new SubmissionServiceStub(); + const sectionsServiceStub: any = new SectionsServiceStub(); + let formService: any; + let formOperationsService: any; + let formBuilderService: any; + let collectionDataService: any; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); + + const licenseText = 'License text'; + const mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1', + }], + license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })), + }); + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + NgxPaginationModule, + NoopAnimationsModule, + TranslateModule.forRoot(), + SubmissionSectionDuplicatesComponent, + TestComponent, + ObjNgFor, + VarDirective, + ], + providers: [ + { provide: CollectionDataService, useValue: getMockCollectionDataService() }, + { provide: SectionFormOperationsService, useValue: getMockFormOperationsService() }, + { provide: FormService, useValue: getMockFormService() }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + { provide: SubmissionFormsConfigDataService, useValue: getMockSubmissionFormsConfigService() }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: 'collectionIdProvider', useValue: collectionId }, + { provide: 'sectionDataProvider', useValue: sectionObject }, + { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: PaginationService, useValue: paginationService }, + ChangeDetectorRef, + { provide: FormBuilderService, useValue: getMockFormBuilderService() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents().then(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.getSectionData.and.returnValue(observableOf(sectionObject)); + testFixture = TestBed.createComponent(SubmissionSectionDuplicatesComponent); + testComp = testFixture.componentInstance; + + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create SubmissionSectionDuplicatesComponent', () => { + expect(testComp).toBeTruthy(); + }); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionDuplicatesComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.inject(SubmissionService); + formService = TestBed.inject(FormService); + formBuilderService = TestBed.inject(FormBuilderService); + formOperationsService = TestBed.inject(SectionFormOperationsService); + collectionDataService = TestBed.inject(CollectionDataService); + compAsAny.pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionObject.id); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + // Test initialisation of the submission section + it('Should init section properly', () => { + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + compAsAny.submissionService.getSubmissionScope.and.returnValue(SubmissionScopeType.WorkspaceItem); + spyOn(comp, 'getSectionStatus').and.returnValue(observableOf(true)); + spyOn(comp, 'getDuplicateData').and.returnValue(observableOf({ potentialDuplicates: duplicates })); + expect(comp.isLoading).toBeTruthy(); + comp.onSectionInit(); + fixture.detectChanges(); + expect(comp.isLoading).toBeFalsy(); + }); + + // The following tests look for proper logic in the getSectionStatus() implementation + // These are very simple as we don't really have a 'false' state unless we're still loading + it('Should return TRUE if the isLoading is FALSE', () => { + compAsAny.isLoading = false; + expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { + a: true, + })); + }); + it('Should return FALSE', () => { + compAsAny.isLoadin = true; + expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { + a: false, + })); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: ``, + standalone: true, + imports: [BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + NgxPaginationModule], +}) +class TestComponent { + +} diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.ts b/src/app/submission/sections/duplicates/section-duplicates.component.ts new file mode 100644 index 00000000000..885511ca524 --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.ts @@ -0,0 +1,145 @@ +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnInit, +} from '@angular/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; + +import { Metadata } from '../../../core/shared/metadata.utils'; +import { WorkspaceitemSectionDuplicatesObject } from '../../../core/submission/models/workspaceitem-section-duplicates.model'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { getItemModuleRoute } from '../../../item-page/item-page-routing-paths'; +import { AlertType } from '../../../shared/alert/alert-type'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { SubmissionService } from '../../submission.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; + +/** + * Detect duplicates step + * + * @author Kim Shepherd + */ +@Component({ + selector: 'ds-submission-section-duplicates', + templateUrl: './section-duplicates.component.html', + changeDetection: ChangeDetectionStrategy.Default, + imports: [ + VarDirective, + NgIf, + AsyncPipe, + TranslateModule, + NgForOf, + ], + standalone: true, +}) + +export class SubmissionSectionDuplicatesComponent extends SectionModelComponent implements OnInit { + protected readonly Metadata = Metadata; + /** + * The Alert categories. + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * Variable to track if the section is loading. + * @type {boolean} + */ + public isLoading = true; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize instance variables. + * + * @param {TranslateService} translate + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {string} injectedCollectionId + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + constructor(protected translate: TranslateService, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + super.ngOnInit(); + } + + /** + * Initialize all instance variables and retrieve configuration. + */ + onSectionInit() { + this.isLoading = false; + } + + /** + * Check if identifier section has read-only visibility + */ + isReadOnly(): boolean { + return true; + } + + /** + * Unsubscribe from all subscriptions, if needed. + */ + onSectionDestroy(): void { + return; + } + + /** + * Get section status. Because this simple component never requires human interaction, this is basically + * always going to be the opposite of "is this section still loading". This is not the place for API response + * error checking but determining whether the step can 'proceed'. + * + * @return Observable + * the section status + */ + public getSectionStatus(): Observable { + return observableOf(!this.isLoading); + } + + /** + * Get duplicate data as observable from the section data + */ + public getDuplicateData(): Observable { + return this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) as + Observable; + } + + /** + * Construct and return an item link for use with a preview item stub + * @param uuid + */ + public getItemLink(uuid: any) { + return new URLCombiner(getItemModuleRoute(), uuid).toString(); + } + + +} diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index 65ddbe0cb09..cd169a76ace 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -1,20 +1,27 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DYNAMIC_FORM_CONTROL_TYPE_GROUP, DynamicFormControlEvent, - DynamicInputModel + DynamicInputModel, } from '@ng-dynamic-forms/core'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; -import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; -import { SectionFormOperationsService } from './section-form-operations.service'; +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { DynamicRowArrayModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; +import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; import { mockInputWithAuthorityValueModel, mockInputWithFormFieldValueModel, @@ -25,11 +32,10 @@ import { mockQualdropInputModel, MockQualdropModel, MockRelationModel, - mockRowGroupModel + mockRowGroupModel, } from '../../../shared/mocks/form-models.mock'; -import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; -import { DynamicRowArrayModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { SectionFormOperationsService } from './section-form-operations.service'; describe('SectionFormOperationsService test suite', () => { let formBuilderService: any; @@ -37,10 +43,10 @@ describe('SectionFormOperationsService test suite', () => { let serviceAsAny: any; const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { - add: jasmine.createSpy('add'), - replace: jasmine.createSpy('replace'), - remove: jasmine.createSpy('remove'), - }); + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); const pathCombiner = new JsonPatchOperationPathCombiner('sections', 'test'); const dynamicFormControlChangeEvent: DynamicFormControlEvent = { @@ -49,7 +55,7 @@ describe('SectionFormOperationsService test suite', () => { control: null, group: null, model: null, - type: 'change' + type: 'change', }; const dynamicFormControlRemoveEvent: DynamicFormControlEvent = { @@ -58,7 +64,7 @@ describe('SectionFormOperationsService test suite', () => { control: null, group: null, model: null, - type: 'remove' + type: 'remove', }; beforeEach(waitForAsync(() => { @@ -67,15 +73,16 @@ describe('SectionFormOperationsService test suite', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }) + useClass: TranslateLoaderMock, + }, + }), ], providers: [ { provide: FormBuilderService, useValue: getMockFormBuilderService() }, { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, - SectionFormOperationsService - ] + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + SectionFormOperationsService, + ], }).compileComponents().then(); })); @@ -114,8 +121,8 @@ describe('SectionFormOperationsService test suite', () => { it('should return the index of the array to which the element belongs', () => { const event = Object.assign({}, dynamicFormControlChangeEvent, { context: { - index: 1 - } + index: 1, + }, }); expect(service.getArrayIndexFromEvent(event)).toBe(1); @@ -126,10 +133,10 @@ describe('SectionFormOperationsService test suite', () => { model: { parent: { parent: { - index: 2 - } - } - } + index: 2, + }, + }, + }, }); spyOn(serviceAsAny, 'isPartOfArrayOfGroup').and.returnValue(true); @@ -157,10 +164,10 @@ describe('SectionFormOperationsService test suite', () => { type: DYNAMIC_FORM_CONTROL_TYPE_GROUP, parent: { context: { - type: DYNAMIC_FORM_CONTROL_TYPE_ARRAY - } - } - } + type: DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + }, + }, + }, }; expect(service.isPartOfArrayOfGroup(model as any)).toBeTruthy(); @@ -168,7 +175,7 @@ describe('SectionFormOperationsService test suite', () => { it('should return false when parent element doesn\'t belong to an array group element', () => { const model = { - parent: null + parent: null, }; expect(service.isPartOfArrayOfGroup(model as any)).toBeFalsy(); @@ -181,19 +188,19 @@ describe('SectionFormOperationsService test suite', () => { const context = { groups: [ { - group: [MockQualdropModel] - } - ] + group: [MockQualdropModel], + }, + ], }; const model = { parent: { parent: { - context: context - } - } + context: context, + }, + }, }; const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: model + model: model, }); const expectMap = new Map(); expectMap.set(MockQualdropModel.qualdropId, [MockQualdropModel.value]); @@ -207,17 +214,17 @@ describe('SectionFormOperationsService test suite', () => { const context = { groups: [ { - group: [MockQualdropModel] - } - ] + group: [MockQualdropModel], + }, + ], }; const model = { parent: { - context: context - } + context: context, + }, }; const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: model + model: model, }); const expectMap = new Map(); expectMap.set(MockQualdropModel.qualdropId, [MockQualdropModel.value]); @@ -247,19 +254,19 @@ describe('SectionFormOperationsService test suite', () => { const context = { groups: [ { - group: [MockQualdropModel] - } - ] + group: [MockQualdropModel], + }, + ], }; const model = { parent: { parent: { - context: context - } - } + context: context, + }, + }, }; const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: model + model: model, }); const expectPath = 'dc.identifier.issn/0'; spyOn(serviceAsAny, 'getArrayIndexFromEvent').and.returnValue(0); @@ -273,17 +280,17 @@ describe('SectionFormOperationsService test suite', () => { const context = { groups: [ { - group: [MockQualdropModel] - } - ] + group: [MockQualdropModel], + }, + ], }; const model = { parent: { - context: context - } + context: context, + }, }; const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: model + model: model, }); const expectPath = 'dc.identifier.issn/0'; spyOn(serviceAsAny, 'getArrayIndexFromEvent').and.returnValue(0); @@ -297,7 +304,7 @@ describe('SectionFormOperationsService test suite', () => { describe('getFieldPathSegmentedFromChangeEvent', () => { it('should return field segmented path properly', () => { const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockQualdropInputModel + model: mockQualdropInputModel, }); formBuilderService.isQualdropGroup.and.returnValues(false, false); @@ -306,7 +313,7 @@ describe('SectionFormOperationsService test suite', () => { it('should return field segmented path properly when model is DynamicQualdropModel', () => { const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: MockQualdropModel + model: MockQualdropModel, }); formBuilderService.isQualdropGroup.and.returnValue(true); @@ -316,8 +323,8 @@ describe('SectionFormOperationsService test suite', () => { it('should return field segmented path properly when model belongs to a DynamicQualdropModel', () => { const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: MockQualdropModel - } + parent: MockQualdropModel, + }, }); formBuilderService.isQualdropGroup.and.returnValues(false, true); @@ -330,8 +337,8 @@ describe('SectionFormOperationsService test suite', () => { it('should return field value properly when model belongs to a DynamicQualdropModel', () => { const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: MockQualdropModel - } + parent: MockQualdropModel, + }, }); formBuilderService.isModelInCustomGroup.and.returnValue(true); const expectedValue = 'test'; @@ -341,18 +348,18 @@ describe('SectionFormOperationsService test suite', () => { it('should return field value properly when model is DynamicRelationGroupModel', () => { const event = Object.assign({}, dynamicFormControlChangeEvent, { - model: MockRelationModel + model: MockRelationModel, }); formBuilderService.isModelInCustomGroup.and.returnValue(false); formBuilderService.isRelationGroup.and.returnValue(true); const expectedValue = { journal: [ 'journal test 1', - 'journal test 2' + 'journal test 2', ], issue: [ 'issue test 1', - 'issue test 2' + 'issue test 2', ], }; @@ -361,7 +368,7 @@ describe('SectionFormOperationsService test suite', () => { it('should return field value properly when model has language', () => { let event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockInputWithLanguageModel + model: mockInputWithLanguageModel, }); formBuilderService.isModelInCustomGroup.and.returnValue(false); formBuilderService.isRelationGroup.and.returnValue(false); @@ -370,19 +377,19 @@ describe('SectionFormOperationsService test suite', () => { expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockInputWithLanguageAndAuthorityModel + model: mockInputWithLanguageAndAuthorityModel, }); - expectedValue = Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityModel.value, {language: mockInputWithLanguageAndAuthorityModel.language}); + expectedValue = Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityModel.value, { language: mockInputWithLanguageAndAuthorityModel.language }); expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockInputWithLanguageAndAuthorityArrayModel + model: mockInputWithLanguageAndAuthorityArrayModel, }); expectedValue = [ Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityArrayModel.value[0], - { language: mockInputWithLanguageAndAuthorityArrayModel.language } - ) + { language: mockInputWithLanguageAndAuthorityArrayModel.language }, + ), ]; expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); @@ -390,7 +397,7 @@ describe('SectionFormOperationsService test suite', () => { it('should return field value properly when model has an object as value', () => { let event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockInputWithFormFieldValueModel + model: mockInputWithFormFieldValueModel, }); formBuilderService.isModelInCustomGroup.and.returnValue(false); formBuilderService.isRelationGroup.and.returnValue(false); @@ -399,14 +406,14 @@ describe('SectionFormOperationsService test suite', () => { expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockInputWithAuthorityValueModel + model: mockInputWithAuthorityValueModel, }); expectedValue = mockInputWithAuthorityValueModel.value; expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); event = Object.assign({}, dynamicFormControlChangeEvent, { - model: mockInputWithObjectValueModel + model: mockInputWithObjectValueModel, }); expectedValue = mockInputWithObjectValueModel.value; @@ -461,8 +468,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: MockQualdropModel - } + parent: MockQualdropModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -481,8 +488,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: MockRelationModel - } + parent: MockRelationModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -502,8 +509,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -523,8 +530,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); const spyPath = spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -552,8 +559,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -577,8 +584,8 @@ describe('SectionFormOperationsService test suite', () => { let previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); const spyPath = spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -607,8 +614,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -632,8 +639,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], null); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -658,8 +665,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); const spyPath = spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -693,8 +700,8 @@ describe('SectionFormOperationsService test suite', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { - parent: mockRowGroupModel - } + parent: mockRowGroupModel, + }, }); spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/1'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); @@ -712,7 +719,7 @@ describe('SectionFormOperationsService test suite', () => { expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( pathCombiner.getPath('path'), new FormFieldMetadataValueObject('test'), - true + true, ); }); }); @@ -808,7 +815,7 @@ describe('SectionFormOperationsService test suite', () => { isDraggable: true, groupFactory: () => { return [ - new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }) + new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }), ]; }, required: false, @@ -816,8 +823,8 @@ describe('SectionFormOperationsService test suite', () => { metadataFields: ['dc.contributor.author'], hasSelectableMetadata: true, showButtons: true, - typeBindRelations: [] - } + typeBindRelations: [], + }, ); spyOn(serviceAsAny, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); previousValue = new FormFieldPreviousValueObject(['path'], null); @@ -831,7 +838,7 @@ describe('SectionFormOperationsService test suite', () => { pathCombiner, dynamicFormControlChangeEvent, arrayModel, - previousValue + previousValue, ); expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); @@ -841,10 +848,10 @@ describe('SectionFormOperationsService test suite', () => { it('should dispatch a json-path add operation when a array value is not empty', () => { const pathValue = [ new FormFieldMetadataValueObject('test'), - new FormFieldMetadataValueObject('test two') + new FormFieldMetadataValueObject('test two'), ]; formBuilderService.getValueFromModel.and.returnValue({ - path:pathValue + path:pathValue, }); spyOn(previousValue, 'isPathEqual').and.returnValue(false); @@ -852,13 +859,13 @@ describe('SectionFormOperationsService test suite', () => { pathCombiner, dynamicFormControlChangeEvent, arrayModel, - previousValue + previousValue, ); expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( pathCombiner.getPath('path'), pathValue, - false + false, ); expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); }); @@ -871,7 +878,7 @@ describe('SectionFormOperationsService test suite', () => { pathCombiner, dynamicFormControlChangeEvent, arrayModel, - previousValue + previousValue, ); expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 778063dd316..6ef2fc51b84 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -1,36 +1,45 @@ import { Injectable } from '@angular/core'; - -import isEqual from 'lodash/isEqual'; -import isObject from 'lodash/isObject'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DYNAMIC_FORM_CONTROL_TYPE_GROUP, DynamicFormArrayGroupModel, DynamicFormControlEvent, DynamicFormControlModel, - isDynamicFormControlEvent + isDynamicFormControlEvent, } from '@ng-dynamic-forms/core'; +import { deepClone } from 'fast-json-patch'; +import isEqual from 'lodash/isEqual'; +import isObject from 'lodash/isObject'; -import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; -import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; -import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; -import { DynamicRelationGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; -import { deepClone } from 'fast-json-patch'; -import { dateToString, isNgbDateStruct } from '../../../shared/date.util'; +import { + dateToString, + isNgbDateStruct, +} from '../../../shared/date.util'; +import { + hasValue, + isNotEmpty, + isNotNull, + isNotUndefined, + isNull, + isUndefined, +} from '../../../shared/empty.util'; +import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { DynamicRowArrayModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { DynamicRelationGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; /** * The service handling all form section operations */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class SectionFormOperationsService { /** @@ -57,9 +66,9 @@ export class SectionFormOperationsService { * representing if field value related to the specified operation has stored value */ public dispatchOperationsFromEvent(pathCombiner: JsonPatchOperationPathCombiner, - event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject, - hasStoredValue: boolean): void { + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean): void { switch (event.type) { case 'remove': this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue); @@ -83,7 +92,7 @@ export class SectionFormOperationsService { * @return number * the array index is part of array, zero otherwise */ - public getArrayIndexFromEvent(event: DynamicFormControlEvent | any): number { + public getArrayIndexFromEvent(event: any): number { let fieldIndex: number; if (isNotEmpty(event)) { @@ -101,7 +110,7 @@ export class SectionFormOperationsService { } else { // This is the case of a custom event which contains indexes information - fieldIndex = event.index as any; + fieldIndex = event?.index as any; } } @@ -296,8 +305,8 @@ export class SectionFormOperationsService { * the [[FormFieldPreviousValueObject]] for the specified operation */ protected dispatchOperationsFromRemoveEvent(pathCombiner: JsonPatchOperationPathCombiner, - event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject): void { + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject): void { const path = this.getFieldPathFromEvent(event); const value = this.getFieldValueFromChangeEvent(event); @@ -321,7 +330,7 @@ export class SectionFormOperationsService { */ protected dispatchOperationsFromAddEvent( pathCombiner: JsonPatchOperationPathCombiner, - event: DynamicFormControlEvent + event: DynamicFormControlEvent, ): void { const path = this.getFieldPathSegmentedFromChangeEvent(event); const value = deepClone(this.getFieldValueFromChangeEvent(event)); @@ -360,11 +369,11 @@ export class SectionFormOperationsService { * representing if field value related to the specified operation has stored value */ protected dispatchOperationsFromChangeEvent(pathCombiner: JsonPatchOperationPathCombiner, - event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject, - hasStoredValue: boolean): void { + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean): void { - if (event.context && event.context instanceof DynamicFormArrayGroupModel) { + if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); return; @@ -399,11 +408,11 @@ export class SectionFormOperationsService { if (isNotEmpty(moveFrom.path) && isNotEmpty(moveTo.path) && moveFrom.path !== moveTo.path) { this.operationsBuilder.move( moveTo, - moveFrom.path + moveFrom.path, ); } } - } else if (!value.hasValue()) { + } else if (isNotEmpty(value) && !value.hasValue()) { // New value is empty, so dispatch a remove operation if (this.getArrayIndexFromEvent(event) === 0) { this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); @@ -448,9 +457,9 @@ export class SectionFormOperationsService { * the [[FormFieldPreviousValueObject]] for the specified operation */ protected dispatchOperationsFromMap(valueMap: Map, - pathCombiner: JsonPatchOperationPathCombiner, - event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject): void { + pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject): void { const currentValueMap = valueMap; if (event.type === 'remove') { const path = this.getQualdropItemPathFromEvent(event); @@ -493,8 +502,8 @@ export class SectionFormOperationsService { * the [[FormFieldPreviousValueObject]] for the specified operation */ private dispatchOperationsFromMoveEvent(pathCombiner: JsonPatchOperationPathCombiner, - event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject) { + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject) { return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel, previousValue); } @@ -512,9 +521,9 @@ export class SectionFormOperationsService { * the [[FormFieldPreviousValueObject]] for the specified operation */ private handleArrayGroupPatch(pathCombiner: JsonPatchOperationPathCombiner, - event, - model: DynamicRowArrayModel, - previousValue: FormFieldPreviousValueObject) { + event, + model: DynamicRowArrayModel, + previousValue: FormFieldPreviousValueObject) { const arrayValue = this.formBuilder.getValueFromModel([model]); const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); @@ -522,7 +531,7 @@ export class SectionFormOperationsService { this.operationsBuilder.add( pathCombiner.getPath(segmentedPath), arrayValue[segmentedPath], - false + false, ); } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); diff --git a/src/app/submission/sections/form/section-form.component.html b/src/app/submission/sections/form/section-form.component.html index 675b550b570..cd7b45bb00c 100644 --- a/src/app/submission/sections/form/section-form.component.html +++ b/src/app/submission/sections/form/section-form.component.html @@ -1,4 +1,4 @@ - + { @@ -144,6 +164,7 @@ describe('SubmissionSectionFormComponent test suite', () => { let submissionServiceStub: SubmissionServiceStub; let notificationsServiceStub: NotificationsServiceStub; let formService: any = getMockFormService(); + let themeService = getMockThemeService(); let formOperationsService: any; let formBuilderService: any; @@ -158,16 +179,13 @@ describe('SubmissionSectionFormComponent test suite', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - BrowserModule, CommonModule, FormsModule, ReactiveFormsModule, - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), FormComponent, SubmissionSectionFormComponent, - TestComponent + TestComponent, ], providers: [ { provide: FormBuilderService, useValue: getMockFormBuilderService() }, @@ -176,18 +194,21 @@ describe('SubmissionSectionFormComponent test suite', () => { { provide: SubmissionFormsConfigDataService, useValue: formConfigService }, { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: SectionsService, useValue: sectionsServiceStub }, + { provide: ThemeService, useValue: themeService }, { provide: SubmissionService, useClass: SubmissionServiceStub }, { provide: TranslateService, useValue: getMockTranslateService() }, - { provide: ObjectCacheService, useValue: { remove: () => {/*do nothing*/}, hasBySelfLinkObservable: () => observableOf(false), hasByHref$: () => observableOf(false) } }, - { provide: RequestService, useValue: { removeByHrefSubstring: () => {/*do nothing*/}, hasByHref$: () => observableOf(false) } }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + { provide: ObjectCacheService, useValue: { remove: () => { }, hasBySelfLinkObservable: () => observableOf(false), hasByHref$: () => observableOf(false) } }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + { provide: RequestService, useValue: { removeByHrefSubstring: () => { }, hasByHref$: () => observableOf(false) } }, { provide: 'collectionIdProvider', useValue: collectionId }, { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, { provide: 'submissionIdProvider', useValue: submissionId }, { provide: SubmissionObjectDataService, useValue: { getHrefByID: () => observableOf('testUrl'), findById: () => createSuccessfulRemoteDataObject$(new WorkspaceItem()) } }, ChangeDetectorRef, - SubmissionSectionFormComponent + SubmissionSectionFormComponent, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents().then(); })); @@ -261,7 +282,7 @@ describe('SubmissionSectionFormComponent test suite', () => { expect(comp.sectionData.errorsToShow).toEqual([]); expect(comp.sectionData.data).toEqual(sectionData); expect(comp.isLoading).toBeFalsy(); - expect(comp.initForm).toHaveBeenCalledWith(sectionData); + expect(comp.initForm).toHaveBeenCalledWith(sectionData, [], []); expect(comp.subscriptions).toHaveBeenCalled(); }); @@ -269,7 +290,7 @@ describe('SubmissionSectionFormComponent test suite', () => { formBuilderService.modelFromConfiguration.and.returnValue(testFormModel); const sectionData = {}; - comp.initForm(sectionData); + comp.initForm(sectionData, [], []); expect(comp.formModel).toEqual(testFormModel); @@ -281,10 +302,10 @@ describe('SubmissionSectionFormComponent test suite', () => { const sectionData = {}; const sectionError: SubmissionSectionError = { message: 'test' + 'Error: test', - path: '/sections/' + sectionObject.id + path: '/sections/' + sectionObject.id, }; - comp.initForm(sectionData); + comp.initForm(sectionData, [], []); expect(comp.formModel).toBeUndefined(); expect(sectionsServiceStub.setSectionError).toHaveBeenCalledWith(submissionId, sectionObject.id, sectionError); @@ -293,11 +314,11 @@ describe('SubmissionSectionFormComponent test suite', () => { it('should return true when has Metadata Enrichment', () => { const newSectionData = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; compAsAny.formData = {}; compAsAny.sectionData.data = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; spyOn(compAsAny, 'inCurrentSubmissionScope').and.callThrough(); @@ -307,11 +328,11 @@ describe('SubmissionSectionFormComponent test suite', () => { it('should return false when has not Metadata Enrichment', () => { const newSectionData = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; compAsAny.formData = newSectionData; compAsAny.sectionData.data = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; spyOn(compAsAny, 'inCurrentSubmissionScope').and.callThrough(); @@ -321,7 +342,7 @@ describe('SubmissionSectionFormComponent test suite', () => { it('should return false when metadata has Metadata Enrichment but not belonging to sectionMetadata', () => { const newSectionData = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; compAsAny.formData = newSectionData; compAsAny.sectionMetadata = []; @@ -338,16 +359,16 @@ describe('SubmissionSectionFormComponent test suite', () => { { selectableMetadata: [{ metadata: 'scoped.workflow' }], scope: 'WORKFLOW', - } as FormFieldModel - ] + } as FormFieldModel, + ], }, { fields: [ { selectableMetadata: [{ metadata: 'scoped.workspace' }], scope: 'WORKSPACE', - } as FormFieldModel - ] + } as FormFieldModel, + ], }, { fields: [ @@ -369,10 +390,10 @@ describe('SubmissionSectionFormComponent test suite', () => { fields: [ { selectableMetadata: [{ metadata: 'dc.title' }], - } as FormFieldModel - ] - } - ] + } as FormFieldModel, + ], + }, + ], }; }); @@ -435,7 +456,7 @@ describe('SubmissionSectionFormComponent test suite', () => { spyOn(comp, 'initForm'); spyOn(comp, 'checksForErrors'); const sectionData: any = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; const sectionError = []; comp.sectionData.data = {}; @@ -443,7 +464,7 @@ describe('SubmissionSectionFormComponent test suite', () => { compAsAny.formData = {}; compAsAny.sectionMetadata = ['dc.title']; - comp.updateForm(sectionData, sectionError); + comp.updateForm({ data: sectionData, errorsToShow: sectionError } as any); expect(comp.isUpdating).toBeFalsy(); expect(comp.initForm).toHaveBeenCalled(); @@ -455,15 +476,19 @@ describe('SubmissionSectionFormComponent test suite', () => { it('should update form error properly', () => { spyOn(comp, 'initForm'); spyOn(comp, 'checksForErrors'); - const sectionData: any = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + const sectionData = { + 'dc.title': [new FormFieldMetadataValueObject('test')], }; + const sectionState = { + data: sectionData, + errorsToShow: [{ path: '/test', message: 'test' }], + } as any; comp.sectionData.data = {}; comp.sectionData.errorsToShow = []; compAsAny.formData = sectionData; compAsAny.sectionMetadata = ['dc.title']; - comp.updateForm(sectionData, parsedSectionErrors); + comp.updateForm(sectionState); expect(comp.initForm).not.toHaveBeenCalled(); expect(comp.checksForErrors).toHaveBeenCalled(); @@ -474,8 +499,9 @@ describe('SubmissionSectionFormComponent test suite', () => { spyOn(comp, 'initForm'); spyOn(comp, 'checksForErrors'); const sectionData: any = {}; + const sectionErrors: any = [{ path: '/test', message: 'test' }]; - comp.updateForm(sectionData, parsedSectionErrors); + comp.updateForm({ data: sectionData, errorsToShow: sectionErrors } as any); expect(comp.initForm).not.toHaveBeenCalled(); expect(comp.checksForErrors).toHaveBeenCalled(); @@ -495,7 +521,7 @@ describe('SubmissionSectionFormComponent test suite', () => { sectionObject.id, 'test', parsedSectionErrors, - [] + [], ); expect(comp.sectionData.errorsToShow).toEqual(parsedSectionErrors); }); @@ -504,7 +530,7 @@ describe('SubmissionSectionFormComponent test suite', () => { formService.isValid.and.returnValue(observableOf(true)); sectionsServiceStub.getSectionServerErrors.and.returnValue(observableOf([])); const expected = cold('(b|)', { - b: true + b: true, }); expect(compAsAny.getSectionStatus()).toBeObservable(expected); @@ -514,7 +540,7 @@ describe('SubmissionSectionFormComponent test suite', () => { formService.isValid.and.returnValue(observableOf(true)); sectionsServiceStub.getSectionServerErrors.and.returnValue(observableOf(parsedSectionErrors)); const expected = cold('(b|)', { - b: false + b: false, }); expect(compAsAny.getSectionStatus()).toBeObservable(expected); @@ -524,7 +550,7 @@ describe('SubmissionSectionFormComponent test suite', () => { formService.isValid.and.returnValue(observableOf(false)); sectionsServiceStub.getSectionServerErrors.and.returnValue(observableOf([])); const expected = cold('(b|)', { - b: false + b: false, }); expect(compAsAny.getSectionStatus()).toBeObservable(expected); @@ -533,15 +559,15 @@ describe('SubmissionSectionFormComponent test suite', () => { it('should subscribe to state properly', () => { spyOn(comp, 'updateForm'); const formData = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; const sectionData: any = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], }; const sectionState = { data: sectionData, - errorsToShow: parsedSectionErrors - }; + errorsToShow: parsedSectionErrors, + } as any; formService.getFormData.and.returnValue(observableOf(formData)); sectionsServiceStub.getSectionState.and.returnValue(observableOf(sectionState)); @@ -550,7 +576,7 @@ describe('SubmissionSectionFormComponent test suite', () => { expect(compAsAny.subs.length).toBe(2); expect(compAsAny.formData).toEqual(formData); - expect(comp.updateForm).toHaveBeenCalledWith(sectionState.data, sectionState.errorsToShow); + expect(comp.updateForm).toHaveBeenCalledWith(sectionState); }); @@ -617,7 +643,7 @@ describe('SubmissionSectionFormComponent test suite', () => { it('should check if has stored value in the section state', () => { comp.sectionData.data = { - 'dc.title': [new FormFieldMetadataValueObject('test')] + 'dc.title': [new FormFieldMetadataValueObject('test')], } as any; expect(comp.hasStoredValue('dc.title', 0)).toBeTruthy(); @@ -631,8 +657,8 @@ describe('SubmissionSectionFormComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, + imports: [CommonModule, FormsModule, ReactiveFormsModule], }) -class TestComponent { - -} +class TestComponent {} diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 2a07f7e3f17..26285320b05 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -1,45 +1,72 @@ -import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; -import { DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core'; - -import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, mergeMap, take, tap } from 'rxjs/operators'; +import { NgIf } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Inject, + ViewChild, +} from '@angular/core'; +import { + DynamicFormControlEvent, + DynamicFormControlModel, +} from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import findIndex from 'lodash/findIndex'; import isEqual from 'lodash/isEqual'; +import { + combineLatest as observableCombineLatest, + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + find, + map, + mergeMap, + take, + tap, +} from 'rxjs/operators'; -import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { FormComponent } from '../../../shared/form/form.component'; -import { FormService } from '../../../shared/form/form.service'; -import { SectionModelComponent } from '../models/section.model'; +import { environment } from '../../../../environments/environment'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { ConfigObject } from '../../../core/config/models/config.model'; +import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; -import { hasValue, isEmpty, isNotEmpty, isUndefined } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; +import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; +import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; +import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { + hasValue, + isEmpty, + isNotEmpty, + isUndefined, +} from '../../../shared/empty.util'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; -import { SectionDataObject } from '../models/section-data.model'; -import { renderSectionFor } from '../sections-decorator'; -import { SectionsType } from '../sections-type'; -import { SubmissionService } from '../../submission.service'; -import { SectionFormOperationsService } from './section-form-operations.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { SectionsService } from '../sections.service'; import { difference } from '../../../shared/object.util'; -import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; -import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; -import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { RequestService } from '../../../core/data/request.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { environment } from '../../../../environments/environment'; -import { ConfigObject } from '../../../core/config/models/config.model'; -import { RemoteData } from '../../../core/data/remote-data'; -import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; -import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; -import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; -import { SubmissionSectionObject } from '../../objects/submission-section-object.model'; import { SubmissionSectionError } from '../../objects/submission-section-error.model'; -import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import { SubmissionSectionObject } from '../../objects/submission-section-object.model'; +import { SubmissionService } from '../../submission.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SectionFormOperationsService } from './section-form-operations.service'; /** * This component represents a section that contains a Form. @@ -48,8 +75,13 @@ import { FormRowModel } from '../../../core/config/models/config-submission-form selector: 'ds-submission-section-form', styleUrls: ['./section-form.component.scss'], templateUrl: './section-form.component.html', + imports: [ + FormComponent, + ThemedLoadingComponent, + NgIf, + ], + standalone: true, }) -@renderSectionFor(SectionsType.SubmissionForm) export class SubmissionSectionFormComponent extends SectionModelComponent { /** @@ -183,7 +215,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.submissionObjectService.findById(this.submissionId, true, false, followLink('item')).pipe( getFirstSucceededRemoteData(), getRemoteDataPayload()), - this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()) + this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()), ])), take(1)) .subscribe(([sectionData, submissionObject, isSectionReadOnly]: [WorkspaceitemSectionFormObject, SubmissionObject, boolean]) => { @@ -192,7 +224,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.submissionObject = submissionObject; this.isSectionReadonly = isSectionReadOnly; // Is the first loading so init form - this.initForm(sectionData); + this.initForm(sectionData, this.sectionData.errorsToShow, this.sectionData.serverValidationErrors); this.sectionData.data = sectionData; this.subscriptions(); this.isLoading = false; @@ -219,11 +251,11 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { protected getSectionStatus(): Observable { const formStatus$ = this.formService.isValid(this.formId); const serverValidationStatus$ = this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( - map((validationErrors) => isEmpty(validationErrors)) + map((validationErrors) => isEmpty(validationErrors)), ); return observableCombineLatest([formStatus$, serverValidationStatus$]).pipe( - map(([formValidation, serverSideValidation]: [boolean, boolean]) => formValidation && serverSideValidation) + map(([formValidation, serverSideValidation]: [boolean, boolean]) => formValidation && serverSideValidation), ); } @@ -278,10 +310,10 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { })?.fields?.[0]?.scope; switch (scope) { - case SubmissionScopeType.WorkspaceItem: { + case SubmissionScopeType.WorkspaceItem.valueOf(): { return (this.submissionObject as any).type === WorkspaceItem.type.value; } - case SubmissionScopeType.WorkflowItem: { + case SubmissionScopeType.WorkflowItem.valueOf(): { return (this.submissionObject as any).type === WorkflowItem.type.value; } default: { @@ -296,7 +328,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * @param sectionData * the section data retrieved from the server */ - initForm(sectionData: WorkspaceitemSectionFormObject): void { + initForm(sectionData: WorkspaceitemSectionFormObject, errorsToShow: SubmissionSectionError[], serverValidationErrors: SubmissionSectionError[]): void { try { this.formModel = this.formBuilderService.modelFromConfiguration( this.submissionId, @@ -304,17 +336,19 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.collectionId, sectionData, this.submissionService.getSubmissionScope(), - this.isSectionReadonly + this.isSectionReadonly, ); const sectionMetadata = this.sectionService.computeSectionConfiguredMetadata(this.formConfig); - this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, sectionData, this.sectionData.errorsToShow, this.sectionData.serverValidationErrors, sectionMetadata); - } catch (e) { - const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString(); + this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, sectionData, errorsToShow, serverValidationErrors, sectionMetadata); + } catch (e: unknown) { + const msg: string = this.translate.instant('error.submission.sections.init-form-error') + (e as Error).toString(); const sectionError: SubmissionSectionError = { message: msg, - path: '/sections/' + this.sectionData.id + path: '/sections/' + this.sectionData.id, }; - console.error(e.stack); + if (e instanceof Error) { + console.error(e.stack); + } this.sectionService.setSectionError(this.submissionId, this.sectionData.id, sectionError); } } @@ -322,12 +356,13 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { /** * Update form model * - * @param sectionData - * the section data retrieved from the server - * @param errors - * the section errors retrieved from the server + * @param sectionState + * the section state retrieved from the server */ - updateForm(sectionData: WorkspaceitemSectionFormObject, errors: SubmissionSectionError[]): void { + updateForm(sectionState: SubmissionSectionObject): void { + + const sectionData = sectionState.data as WorkspaceitemSectionFormObject; + const errors = sectionState.errorsToShow; if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data)) { this.sectionData.data = sectionData; @@ -335,7 +370,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.isUpdating = true; this.formModel = null; this.cdr.detectChanges(); - this.initForm(sectionData); + this.initForm(sectionData, errors, sectionState.serverValidationErrors); this.checksForErrors(errors); this.isUpdating = false; this.cdr.detectChanges(); @@ -389,8 +424,8 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { .subscribe((sectionState: SubmissionSectionObject) => { this.fieldsOnTheirWayToBeRemoved = new Map(); this.sectionMetadata = sectionState.metadata; - this.updateForm(sectionState.data as WorkspaceitemSectionFormObject, sectionState.errorsToShow); - }) + this.updateForm(sectionState); + }), ); } @@ -416,7 +451,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { } private hasRelatedCustomError(medatata): boolean { - const index = findIndex(this.sectionData.errorsToShow, {path: this.pathCombiner.getPath(medatata).path}); + const index = findIndex(this.sectionData.errorsToShow, { path: this.pathCombiner.getPath(medatata).path }); if (index !== -1) { const error = this.sectionData.errorsToShow[index]; const validator = error.message.replace('error.validation.', ''); diff --git a/src/app/submission/sections/identifiers/section-identifiers.component.html b/src/app/submission/sections/identifiers/section-identifiers.component.html index dd0b5d2930a..caf249e5b6e 100644 --- a/src/app/submission/sections/identifiers/section-identifiers.component.html +++ b/src/app/submission/sections/identifiers/section-identifiers.component.html @@ -3,18 +3,17 @@ @author Kim Shepherd --> - - -
- {{'submission.sections.identifiers.info' | translate}} -
    - - -
  • {{'submission.sections.identifiers.' + identifier.identifierType + '_label' | translate}} - {{identifier.value}}
  • + +
    + {{ 'submission.sections.identifiers.info' | translate }} +
      + + +
    • {{ 'submission.sections.identifiers.' + identifier.identifierType + '_label' | translate }} + {{ identifier.value }} +
    • +
      - -
    -
    -
    +
+
diff --git a/src/app/submission/sections/identifiers/section-identifiers.component.spec.ts b/src/app/submission/sections/identifiers/section-identifiers.component.spec.ts index 041e2af23a4..8aa760bb3e3 100644 --- a/src/app/submission/sections/identifiers/section-identifiers.component.spec.ts +++ b/src/app/submission/sections/identifiers/section-identifiers.component.spec.ts @@ -1,59 +1,74 @@ -import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -import { NgxPaginationModule } from 'ngx-pagination'; +import { TranslateModule } from '@ngx-translate/core'; import { cold } from 'jasmine-marbles'; +import { NgxPaginationModule } from 'ngx-pagination'; import { of as observableOf } from 'rxjs'; -import { TranslateModule } from '@ngx-translate/core'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { createTestComponent } from '../../../shared/testing/utils.test'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { SubmissionService } from '../../submission.service'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { SectionsService } from '../sections.service'; -import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; -import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; -import { getMockFormService } from '../../../shared/mocks/form-service.mock'; -import { FormService } from '../../../shared/form/form.service'; import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; -import { SectionDataObject } from '../models/section-data.model'; -import { SectionsType } from '../sections-type'; -import { mockSubmissionCollectionId, mockSubmissionId } from '../../../shared/mocks/submission.mock'; -import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { SubmissionSectionIdentifiersComponent } from './section-identifiers.component'; import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { SectionFormOperationsService } from '../form/section-form-operations.service'; -import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; -import { License } from '../../../core/shared/license.model'; +import { PaginationService } from '../../../core/pagination/pagination.service'; import { Collection } from '../../../core/shared/collection.model'; -import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; -import { VarDirective } from '../../../shared/utils/var.directive'; -import { WorkspaceitemSectionIdentifiersObject } from '../../../core/submission/models/workspaceitem-section-identifiers.model'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; import { Item } from '../../../core/shared/item.model'; -import { PaginationService } from '../../../core/pagination/pagination.service'; +import { License } from '../../../core/shared/license.model'; +import { WorkspaceitemSectionIdentifiersObject } from '../../../core/submission/models/workspaceitem-section-identifiers.model'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormService } from '../../../shared/form/form.service'; +import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; +import { getMockFormService } from '../../../shared/mocks/form-service.mock'; +import { + mockSubmissionCollectionId, + mockSubmissionId, +} from '../../../shared/mocks/submission.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SectionsType } from '../sections-type'; +import { SubmissionSectionIdentifiersComponent } from './section-identifiers.component'; function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService { return jasmine.createSpyObj('FormOperationsService', { getConfigAll: jasmine.createSpy('getConfigAll'), getConfigByHref: jasmine.createSpy('getConfigByHref'), getConfigByName: jasmine.createSpy('getConfigByName'), - getConfigBySearch: jasmine.createSpy('getConfigBySearch') + getConfigBySearch: jasmine.createSpy('getConfigBySearch'), }); } function getMockCollectionDataService(): CollectionDataService { return jasmine.createSpyObj('CollectionDataService', { findById: jasmine.createSpy('findById'), - findByHref: jasmine.createSpy('findByHref') + findByHref: jasmine.createSpy('findByHref'), }); } @@ -64,9 +79,9 @@ const mockItem = Object.assign(new Item(), { 'dc.title': [ { language: null, - value: 'mockmatch' - } - ] + value: 'mockmatch', + }, + ], }, }); @@ -76,16 +91,16 @@ const identifierData: WorkspaceitemSectionIdentifiersObject = { value: 'https://doi.org/10.33515/dspace-61', identifierType: 'doi', identifierStatus: 'TO_BE_REGISTERED', - type: 'identifier' + type: 'identifier', }, { value: '123456789/418', identifierType: 'handle', identifierStatus: null, - type: 'identifier' - } + type: 'identifier', + }, ], - displayTypes: ['doi', 'handle'] + displayTypes: ['doi', 'handle'], }; // Mock section object to use with tests @@ -99,7 +114,7 @@ const sectionObject: SectionDataObject = { header: 'submission.sections.submit.progressbar.identifiers', id: 'identifiers', sectionType: SectionsType.Identifiers, - sectionVisibility: null + sectionVisibility: null, }; describe('SubmissionSectionIdentifiersComponent test suite', () => { @@ -121,6 +136,15 @@ describe('SubmissionSectionIdentifiersComponent test suite', () => { remove: jasmine.createSpy('remove'), }); + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test', + ], + })), + }); + const licenseText = 'License text'; const mockCollection = Object.assign(new Collection(), { name: 'Community 1-Collection 1', @@ -129,24 +153,21 @@ describe('SubmissionSectionIdentifiersComponent test suite', () => { { key: 'dc.title', language: 'en_US', - value: 'Community 1-Collection 1' + value: 'Community 1-Collection 1', }], - license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })) + license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })), }); const paginationService = new PaginationServiceStub(); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - BrowserModule, CommonModule, FormsModule, ReactiveFormsModule, NgxPaginationModule, NoopAnimationsModule, TranslateModule.forRoot(), - ], - declarations: [ SubmissionSectionIdentifiersComponent, TestComponent, ObjNgFor, @@ -165,11 +186,12 @@ describe('SubmissionSectionIdentifiersComponent test suite', () => { { provide: 'sectionDataProvider', useValue: sectionObject }, { provide: 'submissionIdProvider', useValue: submissionId }, { provide: PaginationService, useValue: paginationService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, ChangeDetectorRef, FormBuilderService, - SubmissionSectionIdentifiersComponent + SubmissionSectionIdentifiersComponent, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents().then(); })); @@ -235,13 +257,13 @@ describe('SubmissionSectionIdentifiersComponent test suite', () => { it('Should return TRUE if the isLoading is FALSE', () => { compAsAny.isLoading = false; expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { - a: true + a: true, })); }); it('Should return FALSE if the identifier data is missing handle', () => { compAsAny.isLoadin = true; expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { - a: false + a: false, })); }); }); @@ -251,7 +273,13 @@ describe('SubmissionSectionIdentifiersComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + NgxPaginationModule], }) class TestComponent { diff --git a/src/app/submission/sections/identifiers/section-identifiers.component.ts b/src/app/submission/sections/identifiers/section-identifiers.component.ts index ac4af63adb2..759b65c6418 100644 --- a/src/app/submission/sections/identifiers/section-identifiers.component.ts +++ b/src/app/submission/sections/identifiers/section-identifiers.component.ts @@ -1,15 +1,29 @@ -import {ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnInit, +} from '@angular/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; -import { Observable, of as observableOf, Subscription } from 'rxjs'; -import { TranslateService } from '@ngx-translate/core'; -import { SectionsType } from '../sections-type'; +import { WorkspaceitemSectionIdentifiersObject } from '../../../core/submission/models/workspaceitem-section-identifiers.model'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { SubmissionService } from '../../submission.service'; import { SectionModelComponent } from '../models/section.model'; -import { renderSectionFor } from '../sections-decorator'; import { SectionDataObject } from '../models/section-data.model'; -import { SubmissionService } from '../../submission.service'; -import { AlertType } from '../../../shared/alert/alert-type'; import { SectionsService } from '../sections.service'; -import { WorkspaceitemSectionIdentifiersObject } from '../../../core/submission/models/workspaceitem-section-identifiers.model'; /** * This simple component displays DOI, handle and other identifiers that are already minted for the item in @@ -21,16 +35,18 @@ import { WorkspaceitemSectionIdentifiersObject } from '../../../core/submission/ @Component({ selector: 'ds-submission-section-identifiers', templateUrl: './section-identifiers.component.html', - changeDetection: ChangeDetectionStrategy.Default + changeDetection: ChangeDetectionStrategy.Default, + imports: [ + TranslateModule, + NgForOf, + NgIf, + AsyncPipe, + VarDirective, + ], + standalone: true, }) -@renderSectionFor(SectionsType.Identifiers) -export class SubmissionSectionIdentifiersComponent extends SectionModelComponent { - /** - * The Alert categories. - * @type {AlertType} - */ - public AlertTypeEnum = AlertType; +export class SubmissionSectionIdentifiersComponent extends SectionModelComponent implements OnInit { /** * Variable to track if the section is loading. @@ -42,19 +58,11 @@ export class SubmissionSectionIdentifiersComponent extends SectionModelComponent * Observable identifierData subject * @type {Observable} */ - public identifierData$: Observable = new Observable(); - - /** - * Array to track all subscriptions and unsubscribe them onDestroy - * @type {Array} - */ - protected subs: Subscription[] = []; - public subbedIdentifierData: WorkspaceitemSectionIdentifiersObject; + public identifierData$: Observable; /** * Initialize instance variables. * - * @param {PaginationService} paginationService * @param {TranslateService} translate * @param {SectionsService} sectionService * @param {SubmissionService} submissionService @@ -71,10 +79,6 @@ export class SubmissionSectionIdentifiersComponent extends SectionModelComponent super(injectedCollectionId, injectedSectionData, injectedSubmissionId); } - ngOnInit() { - super.ngOnInit(); - } - /** * Initialize all instance variables and retrieve configuration. */ @@ -83,13 +87,6 @@ export class SubmissionSectionIdentifiersComponent extends SectionModelComponent this.identifierData$ = this.getIdentifierData(); } - /** - * Check if identifier section has read-only visibility - */ - isReadOnly(): boolean { - return true; - } - /** * Unsubscribe from all subscriptions, if needed. */ diff --git a/src/app/submission/sections/license/section-license.component.spec.ts b/src/app/submission/sections/license/section-license.component.spec.ts index 35fea95b8df..95b2e7f50ab 100644 --- a/src/app/submission/sections/license/section-license.component.spec.ts +++ b/src/app/submission/sections/license/section-license.component.spec.ts @@ -1,43 +1,80 @@ -import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; - -import { of as observableOf } from 'rxjs'; +import { + ChangeDetectorRef, + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + DYNAMIC_FORM_CONTROL_MAP_FN, + DynamicCheckboxModel, + DynamicFormControlEvent, + DynamicFormControlEventType, +} from '@ng-dynamic-forms/core'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { DynamicCheckboxModel, DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { DsDynamicTypeBindRelationService } from 'src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; +import { + APP_CONFIG, + APP_DATA_SERVICES_MAP, +} from 'src/config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { createTestComponent } from '../../../shared/testing/utils.test'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { SubmissionService } from '../../submission.service'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { SectionsService } from '../sections.service'; -import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { Collection } from '../../../core/shared/collection.model'; +import { License } from '../../../core/shared/license.model'; +import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; +import { XSRFService } from '../../../core/xsrf/xsrf.service'; +import { dsDynamicFormControlMapFn } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; import { getMockFormService } from '../../../shared/mocks/form-service.mock'; -import { FormService } from '../../../shared/form/form.service'; -import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; -import { SectionDataObject } from '../models/section-data.model'; -import { SectionsType } from '../sections-type'; import { mockLicenseParsedErrors, mockSubmissionCollectionId, - mockSubmissionId + mockSubmissionId, + mockSubmissionObject, } from '../../../shared/mocks/submission.mock'; -import { FormComponent } from '../../../shared/form/form.component'; -import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { SubmissionSectionLicenseComponent } from './section-license.component'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { SubmissionService } from '../../submission.service'; import { SectionFormOperationsService } from '../form/section-form-operations.service'; -import { Collection } from '../../../core/shared/collection.model'; -import { License } from '../../../core/shared/license.model'; -import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { cold } from 'jasmine-marbles'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SectionsType } from '../sections-type'; +import { SubmissionSectionLicenseComponent } from './section-license.component'; + +function getMockDsDynamicTypeBindRelationService(): DsDynamicTypeBindRelationService { + return jasmine.createSpyObj('DsDynamicTypeBindRelationService', { + getRelatedFormModel: jasmine.createSpy('getRelatedFormModel'), + matchesCondition: jasmine.createSpy('matchesCondition'), + subscribeRelations: jasmine.createSpy('subscribeRelations'), + }); +} const collectionId = mockSubmissionCollectionId; const licenseText = 'License text'; @@ -48,9 +85,9 @@ const mockCollection = Object.assign(new Collection(), { { key: 'dc.title', language: 'en_US', - value: 'Community 1-Collection 1' + value: 'Community 1-Collection 1', }], - license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })) + license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })), }); function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService { @@ -58,7 +95,7 @@ function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService getConfigAll: jasmine.createSpy('getConfigAll'), getConfigByHref: jasmine.createSpy('getConfigByHref'), getConfigByName: jasmine.createSpy('getConfigByName'), - getConfigBySearch: jasmine.createSpy('getConfigBySearch') + getConfigBySearch: jasmine.createSpy('getConfigBySearch'), }); } @@ -68,13 +105,13 @@ const sectionObject: SectionDataObject = { data: { url: null, acceptanceDate: null, - granted: false + granted: false, }, errorsToShow: [], serverValidationErrors: [], header: 'submit.progressbar.describe.license', id: 'license', - sectionType: SectionsType.License + sectionType: SectionsType.License, }; const dynamicFormControlEvent: DynamicFormControlEvent = { @@ -83,7 +120,7 @@ const dynamicFormControlEvent: DynamicFormControlEvent = { control: null, group: null, model: null, - type: DynamicFormControlEventType.Change + type: DynamicFormControlEventType.Change, }; describe('SubmissionSectionLicenseComponent test suite', () => { @@ -108,22 +145,27 @@ describe('SubmissionSectionLicenseComponent test suite', () => { const mockCollectionDataService = jasmine.createSpyObj('CollectionDataService', { findById: jasmine.createSpy('findById'), - findByHref: jasmine.createSpy('findByHref') + findByHref: jasmine.createSpy('findByHref'), }); - + const initialState: any = { + core: { + 'cache/object': {}, + 'cache/syncbuffer': {}, + 'cache/object-updates': {}, + 'data/request': {}, + 'index': {}, + }, + }; beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + void TestBed.configureTestingModule({ imports: [ - BrowserModule, CommonModule, FormsModule, ReactiveFormsModule, - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), FormComponent, SubmissionSectionLicenseComponent, - TestComponent + TestComponent, ], providers: [ { provide: CollectionDataService, useValue: mockCollectionDataService }, @@ -138,10 +180,22 @@ describe('SubmissionSectionLicenseComponent test suite', () => { { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, { provide: 'submissionIdProvider', useValue: submissionId }, ChangeDetectorRef, + provideMockStore({ initialState }), FormBuilderService, - SubmissionSectionLicenseComponent + { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, + { provide: APP_CONFIG, useValue: environment }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { + provide: SubmissionObjectDataService, + useValue: { + findById: () => observableOf(createSuccessfulRemoteDataObject(mockSubmissionObject)), + }, + }, + { provide: XSRFService, useValue: {} }, + SubmissionSectionLicenseComponent, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents().then(); })); @@ -198,14 +252,13 @@ describe('SubmissionSectionLicenseComponent test suite', () => { mockCollectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + spyOn(formBuilderService, 'findById').and.returnValue(new DynamicCheckboxModel({ id: 'granted' })); }); it('should init section properly', () => { - + fixture.detectChanges(); spyOn(compAsAny, 'getSectionStatus'); - comp.onSectionInit(); - const model = formBuilderService.findById('granted', comp.formModel); expect(compAsAny.subs.length).toBe(2); @@ -213,7 +266,7 @@ describe('SubmissionSectionLicenseComponent test suite', () => { expect(model.value).toBeFalsy(); expect(comp.licenseText$).toBeObservable(cold('(ab|)', { a: '', - b: licenseText + b: licenseText, })); }); @@ -221,39 +274,34 @@ describe('SubmissionSectionLicenseComponent test suite', () => { comp.sectionData.data = { url: 'url', acceptanceDate: Date.now(), - granted: true + granted: true, } as any; - - spyOn(compAsAny, 'getSectionStatus'); - - comp.onSectionInit(); - + fixture.detectChanges(); const model = formBuilderService.findById('granted', comp.formModel); - expect(compAsAny.subs.length).toBe(2); expect(comp.formModel).toBeDefined(); expect(model.value).toBeTruthy(); expect(comp.licenseText$).toBeObservable(cold('(ab|)', { a: '', - b: licenseText + b: licenseText, })); }); - it('should have status true when checkbox is selected', () => { + it('should have status true when checkbox is selected', (done) => { fixture.detectChanges(); - const model = formBuilderService.findById('granted', comp.formModel); + const model = formBuilderService.findById('granted', comp.formModel); (model as DynamicCheckboxModel).value = true; compAsAny.getSectionStatus().subscribe((status) => { expect(status).toBeTruthy(); + done(); }); }); it('should have status false when checkbox is not selected', () => { fixture.detectChanges(); const model = formBuilderService.findById('granted', comp.formModel); - compAsAny.getSectionStatus().subscribe((status) => { expect(status).toBeFalsy(); }); @@ -271,7 +319,7 @@ describe('SubmissionSectionLicenseComponent test suite', () => { }); it('should set section errors properly', () => { - comp.onSectionInit(); + fixture.detectChanges(); const expectedErrors = mockLicenseParsedErrors.license; expect(sectionsServiceStub.checkSectionErrors).toHaveBeenCalled(); @@ -283,10 +331,10 @@ describe('SubmissionSectionLicenseComponent test suite', () => { comp.sectionData.data = { url: 'url', acceptanceDate: Date.now(), - granted: true + granted: true, } as any; - comp.onSectionInit(); + fixture.detectChanges(); expect(sectionsServiceStub.dispatchRemoveSectionErrors).toHaveBeenCalled(); @@ -325,7 +373,15 @@ describe('SubmissionSectionLicenseComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, + imports: [ + SubmissionSectionLicenseComponent, + CommonModule, + FormsModule, + FormComponent, + ReactiveFormsModule, + ], }) class TestComponent { diff --git a/src/app/submission/sections/license/section-license.component.ts b/src/app/submission/sections/license/section-license.component.ts index e9a0cf15668..86a0455c307 100644 --- a/src/app/submission/sections/license/section-license.component.ts +++ b/src/app/submission/sections/license/section-license.component.ts @@ -1,13 +1,35 @@ -import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + AfterViewChecked, + ChangeDetectorRef, + Component, + Inject, + ViewChild, +} from '@angular/core'; import { DynamicCheckboxModel, DynamicFormControlEvent, DynamicFormControlModel, - DynamicFormLayout + DynamicFormLayout, } from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + find, + map, + mergeMap, + startWith, + take, +} from 'rxjs/operators'; -import { Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, mergeMap, startWith, take } from 'rxjs/operators'; import { CollectionDataService } from '../../../core/data/collection-data.service'; import { RemoteData } from '../../../core/data/remote-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; @@ -15,21 +37,25 @@ import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/jso import { Collection } from '../../../core/shared/collection.model'; import { License } from '../../../core/shared/license.model'; import { WorkspaceitemSectionLicenseObject } from '../../../core/submission/models/workspaceitem-section-license.model'; -import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../shared/empty.util'; +import { + hasValue, + isNotEmpty, + isNotNull, + isNotUndefined, +} from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../shared/form/form.component'; import { FormService } from '../../../shared/form/form.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { SubmissionService } from '../../submission.service'; import { SectionFormOperationsService } from '../form/section-form-operations.service'; -import { SectionDataObject } from '../models/section-data.model'; - import { SectionModelComponent } from '../models/section.model'; -import { renderSectionFor } from '../sections-decorator'; -import { SectionsType } from '../sections-type'; +import { SectionDataObject } from '../models/section-data.model'; import { SectionsService } from '../sections.service'; -import { SECTION_LICENSE_FORM_LAYOUT, SECTION_LICENSE_FORM_MODEL } from './section-license.model'; -import { TranslateService } from '@ngx-translate/core'; +import { + SECTION_LICENSE_FORM_LAYOUT, + SECTION_LICENSE_FORM_MODEL, +} from './section-license.model'; /** * This component represents a section that contains the submission license form. @@ -38,9 +64,15 @@ import { TranslateService } from '@ngx-translate/core'; selector: 'ds-submission-section-license', styleUrls: ['./section-license.component.scss'], templateUrl: './section-license.component.html', + providers: [], + imports: [ + FormComponent, + NgIf, + AsyncPipe, + ], + standalone: true, }) -@renderSectionFor(SectionsType.License) -export class SubmissionSectionLicenseComponent extends SectionModelComponent { +export class SubmissionSectionLicenseComponent extends SectionModelComponent implements AfterViewChecked { /** * The form id @@ -130,7 +162,9 @@ export class SubmissionSectionLicenseComponent extends SectionModelComponent { const model = this.formBuilderService.findById('granted', this.formModel); // Translate checkbox label - model.label = this.translateService.instant(model.label); + if (model.label) { + model.label = this.translateService.instant(model.label); + } // Retrieve license accepted status (model as DynamicCheckboxModel).value = (this.sectionData.data as WorkspaceitemSectionLicenseObject).granted; @@ -181,11 +215,14 @@ export class SubmissionSectionLicenseComponent extends SectionModelComponent { // Remove any section's errors this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); } - this.changeDetectorRef.detectChanges(); - }) + }), ); } + ngAfterViewChecked(): void { + this.changeDetectorRef.detectChanges(); + } + /** * Get section status * @@ -213,6 +250,7 @@ export class SubmissionSectionLicenseComponent extends SectionModelComponent { } else { this.operationsBuilder.remove(this.pathCombiner.getPath(path)); } + this.submissionService.dispatchSaveSection(this.submissionId, this.sectionData.id); } /** diff --git a/src/app/submission/sections/license/section-license.model.ts b/src/app/submission/sections/license/section-license.model.ts index 0f21ef98a43..33f7721ac3d 100644 --- a/src/app/submission/sections/license/section-license.model.ts +++ b/src/app/submission/sections/license/section-license.model.ts @@ -5,9 +5,9 @@ export const SECTION_LICENSE_FORM_LAYOUT = { element: { container: 'custom-control custom-checkbox pl-1', control: 'custom-control-input', - label: 'custom-control-label pt-1' - } - } + label: 'custom-control-label pt-1', + }, + }, }; export const SECTION_LICENSE_FORM_MODEL = [ @@ -17,12 +17,12 @@ export const SECTION_LICENSE_FORM_MODEL = [ required: true, value: false, validators: { - required: null + required: null, }, errorMessages: { required: 'submission.sections.license.required', - notgranted: 'submission.sections.license.notgranted' + notgranted: 'submission.sections.license.notgranted', }, type: 'CHECKBOX', - } + }, ]; diff --git a/src/app/submission/sections/models/section-data.model.ts b/src/app/submission/sections/models/section-data.model.ts index 6f8126dffc0..7ecb820db77 100644 --- a/src/app/submission/sections/models/section-data.model.ts +++ b/src/app/submission/sections/models/section-data.model.ts @@ -1,6 +1,6 @@ import { WorkspaceitemSectionDataType } from '../../../core/submission/models/workspaceitem-sections.model'; -import { SectionsType } from '../sections-type'; import { SubmissionSectionError } from '../../objects/submission-section-error.model'; +import { SectionsType } from '../sections-type'; /** * An interface to represent section model diff --git a/src/app/submission/sections/models/section.model.ts b/src/app/submission/sections/models/section.model.ts index c9be0792134..4868f0c6c13 100644 --- a/src/app/submission/sections/models/section.model.ts +++ b/src/app/submission/sections/models/section.model.ts @@ -1,11 +1,24 @@ -import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; - -import { Observable, Subscription } from 'rxjs'; -import { filter, startWith } from 'rxjs/operators'; - -import { SectionDataObject } from './section-data.model'; +import { + Component, + Inject, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + filter, + startWith, +} from 'rxjs/operators'; + +import { + hasValue, + isNotUndefined, +} from '../../../shared/empty.util'; import { SectionsService } from '../sections.service'; -import { hasValue, isNotUndefined } from '../../../shared/empty.util'; +import { SectionDataObject } from './section-data.model'; export interface SectionDataModel { sectionData: SectionDataObject; @@ -16,7 +29,7 @@ export interface SectionDataModel { */ @Component({ selector: 'ds-section-model', - template: '' + template: '', }) export abstract class SectionModelComponent implements OnDestroy, OnInit, SectionDataModel { protected abstract sectionService: SectionsService; diff --git a/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts new file mode 100644 index 00000000000..cfad518b852 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.spec.ts @@ -0,0 +1,96 @@ +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RestResponse } from '../../../core/cache/response.models'; +import { CreateData } from '../../../core/data/base/create-data'; +import { testCreateDataImplementation } from '../../../core/data/base/create-data.spec'; +import { DeleteData } from '../../../core/data/base/delete-data'; +import { testDeleteDataImplementation } from '../../../core/data/base/delete-data.spec'; +import { FindAllData } from '../../../core/data/base/find-all-data'; +import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec'; +import { PatchData } from '../../../core/data/base/patch-data'; +import { testPatchDataImplementation } from '../../../core/data/base/patch-data.spec'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; +import { RequestEntryState } from '../../../core/data/request-entry-state.model'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { CoarNotifyConfigDataService } from './coar-notify-config-data.service'; + +describe('CoarNotifyConfigDataService test', () => { + let scheduler: TestScheduler; + let service: CoarNotifyConfigDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let responseCacheEntry: RequestEntry; + + const endpointURL = `https://rest.api/rest/api/coar-notify`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new CoarNotifyConfigDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + notificationsService = {} as NotificationsService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }), + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initCreateService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as CreateData; + const initFindAllService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as FindAllData; + const initDeleteService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as DeleteData; + const initPatchService = () => new CoarNotifyConfigDataService(null, null, null, null, null) as unknown as PatchData; + testCreateDataImplementation(initCreateService); + testFindAllDataImplementation(initFindAllService); + testPatchDataImplementation(initPatchService); + testDeleteDataImplementation(initDeleteService); + }); + +}); diff --git a/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts new file mode 100644 index 00000000000..74b2f0b97ea --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/coar-notify-config-data.service.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { + CreateData, + CreateDataImpl, +} from '../../../core/data/base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from '../../../core/data/base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from '../../../core/data/base/find-all-data'; +import { IdentifiableDataService } from '../../../core/data/base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from '../../../core/data/base/patch-data'; +import { ChangeAnalyzer } from '../../../core/data/change-analyzer'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { MultipartPostRequest } from '../../../core/data/request.models'; +import { RequestService } from '../../../core/data/request.service'; +import { RestRequest } from '../../../core/data/rest-request.model'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { SubmissionCoarNotifyConfig } from './submission-coar-notify.config'; + + +/** + * A service responsible for fetching/sending data from/to the REST API on the CoarNotifyConfig endpoint + */ +@Injectable({ providedIn: 'root' }) +export class CoarNotifyConfigDataService extends IdentifiableDataService implements FindAllData, DeleteData, PatchData, CreateData { + createData: CreateDataImpl; + private findAllData: FindAllDataImpl; + private deleteData: DeleteDataImpl; + private patchData: PatchDataImpl; + private comparator: ChangeAnalyzer; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('submissioncoarnotifyconfigs', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.comparator, this.responseMsToLive, this.constructIdEndpoint); + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + } + + + create(object: SubmissionCoarNotifyConfig, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + patch(object: SubmissionCoarNotifyConfig, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + update(object: SubmissionCoarNotifyConfig): Observable> { + return this.patchData.update(object); + } + + commitUpdates(method?: RestRequestMethod): void { + return this.patchData.commitUpdates(method); + } + + createPatchFromCache(object: SubmissionCoarNotifyConfig): Observable { + return this.patchData.createPatchFromCache(object); + } + + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + public invoke(serviceName: string, serviceId: string, files: File[]): Observable> { + const requestId = this.requestService.generateRequestId(); + this.getBrowseEndpoint().pipe( + take(1), + map((endpoint: string) => new URLCombiner(endpoint, serviceName, 'submissioncoarnotifyconfigmodel', serviceId).toString()), + map((endpoint: string) => { + const body = this.getInvocationFormData(files); + return new MultipartPostRequest(requestId, endpoint, body); + }), + ).subscribe((request: RestRequest) => this.requestService.send(request)); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + private getInvocationFormData(files: File[]): FormData { + const form: FormData = new FormData(); + files.forEach((file: File) => { + form.append('file', file); + }); + return form; + } +} diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts b/src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts new file mode 100644 index 00000000000..53e41783ced --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify-service.resource-type.ts @@ -0,0 +1,13 @@ +/** + * The resource type for Ldn-Services + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +import { ResourceType } from '../../../core/shared/resource-type'; + + +export const SUBMISSION_COAR_NOTIFY_CONFIG = new ResourceType('submissioncoarnotifyconfig'); + +export const COAR_NOTIFY_WORKSPACEITEM = new ResourceType('workspaceitem'); + diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.html b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.html new file mode 100644 index 00000000000..49742892caa --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.html @@ -0,0 +1,149 @@ +
+ +
+
+ +
+
+
+
+
+ + +
+ +
+ +
+ + + {{'submission.section.section-coar-notify.small.notification' | translate : {pattern : ldnPattern.pattern} }} + + + + {{ error.message | translate}} + + +
+
+ +
+
{{ 'submission.section.section-coar-notify.selection.description' | translate }}
+
+ {{ ldnServiceByPattern[ldnPattern.pattern].services[serviceIndex].description }} +
+ + + {{ 'submission.section.section-coar-notify.selection.no-description' | translate }} + + +
+
+
+
+
+
+ + {{ 'submission.section.section-coar-notify.notification.error' | translate }} + +
+
+
+
+ +
+
+
+
+
+
+ +

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

+
+
diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss new file mode 100644 index 00000000000..4a3e7072a3b --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.scss @@ -0,0 +1,5 @@ +// Getting styles for NgbDropdown +@import '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss'; +@import '../../../shared/form/form.component.scss'; + + diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts new file mode 100644 index 00000000000..635b0a9f444 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.spec.ts @@ -0,0 +1,445 @@ +import { ChangeDetectorRef } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { LdnServicesService } from '../../../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { NotifyServicePattern } from '../../../admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model'; +import { + LdnService, + LdnServiceByPattern, +} from '../../../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { SectionsService } from '../sections.service'; +import { CoarNotifyConfigDataService } from './coar-notify-config-data.service'; +import { SubmissionSectionCoarNotifyComponent } from './section-coar-notify.component'; +import { SubmissionCoarNotifyConfig } from './submission-coar-notify.config'; + +describe('SubmissionSectionCoarNotifyComponent', () => { + let component: SubmissionSectionCoarNotifyComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + + let ldnServicesService: jasmine.SpyObj; + let coarNotifyConfigDataService: jasmine.SpyObj; + let operationsBuilder: jasmine.SpyObj; + let sectionService: jasmine.SpyObj; + let cdRefStub: any; + + + const patterns: SubmissionCoarNotifyConfig[] = Object.assign( + [new SubmissionCoarNotifyConfig()], + { + patterns: [{ pattern: 'review', multipleRequest: false }, { pattern: 'endorsment', multipleRequest: false }], + }, + ); + const patternsPL = createPaginatedList(patterns); + const coarNotifyConfig = createSuccessfulRemoteDataObject$(patternsPL); + + beforeEach(async () => { + ldnServicesService = jasmine.createSpyObj('LdnServicesService', [ + 'findByInboundPattern', + ]); + coarNotifyConfigDataService = jasmine.createSpyObj( + 'CoarNotifyConfigDataService', + ['findAll'], + ); + operationsBuilder = jasmine.createSpyObj('JsonPatchOperationsBuilder', [ + 'remove', + 'replace', + 'add', + 'flushOperation', + ]); + sectionService = jasmine.createSpyObj('SectionsService', [ + 'dispatchRemoveSectionErrors', + 'getSectionServerErrors', + 'setSectionError', + ]); + cdRefStub = Object.assign({ + detectChanges: () => fixture.detectChanges(), + }); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SubmissionSectionCoarNotifyComponent], + providers: [ + { provide: LdnServicesService, useValue: ldnServicesService }, + { provide: CoarNotifyConfigDataService, useValue: coarNotifyConfigDataService }, + { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, + { provide: SectionsService, useValue: sectionService }, + { provide: ChangeDetectorRef, useValue: cdRefStub }, + { provide: 'collectionIdProvider', useValue: 'collectionId' }, + { provide: 'sectionDataProvider', useValue: { id: 'sectionId', data: {} } }, + { provide: 'submissionIdProvider', useValue: 'submissionId' }, + NgbDropdown, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubmissionSectionCoarNotifyComponent); + component = fixture.componentInstance; + componentAsAny = component; + + component.patterns = patterns[0].patterns; + coarNotifyConfigDataService.findAll.and.returnValue(coarNotifyConfig); + sectionService.getSectionServerErrors.and.returnValue( + of( + Object.assign([], { + path: 'sections/sectionId/data/notifyCoar', + message: 'error', + }), + ), + ); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('onSectionInit', () => { + it('should call setCoarNotifyConfig and getSectionServerErrorsAndSetErrorsToDisplay', () => { + spyOn(component, 'setCoarNotifyConfig'); + spyOn(componentAsAny, 'getSectionServerErrorsAndSetErrorsToDisplay'); + + component.onSectionInit(); + + expect(component.setCoarNotifyConfig).toHaveBeenCalled(); + expect(componentAsAny.getSectionServerErrorsAndSetErrorsToDisplay).toHaveBeenCalled(); + }); + }); + + describe('onChange', () => { + const ldnPattern = { pattern: 'review', multipleRequest: false }; + const index = 0; + const selectedService: LdnService = Object.assign(new LdnService(), { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [ + { + pattern: 'review', + }, + ], + description: '', + }); + + beforeEach(() => { + component.ldnServiceByPattern[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [], + } as LdnServiceByPattern; + + component.patterns = []; + }); + + it('should do nothing if the selected value is the same as the previous one', () => { + + component.ldnServiceByPattern[ldnPattern.pattern].services[index] = selectedService; + component.onChange(ldnPattern.pattern, index, selectedService); + + expect(componentAsAny.operationsBuilder.remove).not.toHaveBeenCalled(); + expect(componentAsAny.operationsBuilder.replace).not.toHaveBeenCalled(); + expect(componentAsAny.operationsBuilder.add).not.toHaveBeenCalled(); + }); + + it('should remove the path when the selected value is null', () => { + component.ldnServiceByPattern[ldnPattern.pattern].services[index] = selectedService; + component.onChange(ldnPattern.pattern, index, null); + + expect(componentAsAny.operationsBuilder.flushOperation).toHaveBeenCalledWith( + componentAsAny.pathCombiner.getPath([ldnPattern.pattern, '-']), + ); + expect(component.ldnServiceByPattern[ldnPattern.pattern].services[index]).toBeNull(); + expect(component.previousServices[ldnPattern.pattern].services[index]).toBeNull(); + }); + + it('should replace the path when there is a previous value stored and it is different from the new one', () => { + const previousService: LdnService = Object.assign(new LdnService(), { + id: 2, + name: 'service2', + notifyServiceInboundPatterns: [ + { + pattern: 'endorsement', + }, + ], + description: 'test', + }); + component.ldnServiceByPattern[ldnPattern.pattern].services[index] = previousService; + component.previousServices[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [previousService], + } as LdnServiceByPattern; + + component.onChange(ldnPattern.pattern, index, selectedService); + + expect(componentAsAny.operationsBuilder.add).toHaveBeenCalledWith( + componentAsAny.pathCombiner.getPath([ldnPattern.pattern, '-']), + [selectedService.id], + false, + true, + ); + expect(component.ldnServiceByPattern[ldnPattern.pattern].services[index]).toEqual( + selectedService, + ); + expect(component.previousServices[ldnPattern.pattern].services[index].id).toEqual( + selectedService.id, + ); + }); + + it('should add the path when there is no previous value stored', () => { + component.onChange(ldnPattern.pattern, index, selectedService); + + expect(componentAsAny.operationsBuilder.add).toHaveBeenCalledWith( + componentAsAny.pathCombiner.getPath([ldnPattern.pattern, '-']), + [selectedService.id], + false, + true, + ); + expect(component.ldnServiceByPattern[ldnPattern.pattern].services[index]).toEqual( + selectedService, + ); + expect(component.previousServices[ldnPattern.pattern].services[index].id).toEqual( + selectedService.id, + ); + }); + }); + + describe('initSelectedServicesByPattern', () => { + const pattern1 = { pattern: 'review', multipleRequest: false }; + const pattern2 = { pattern: 'endorsement', multipleRequest: false }; + const service1: LdnService = Object.assign(new LdnService(), { + id: 1, + uuid: 1, + name: 'service1', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: pattern1, + }), + ], + }); + const service2: LdnService = Object.assign(new LdnService(), { + id: 2, + uuid: 2, + name: 'service2', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: pattern2, + }), + ], + }); + const service3: LdnService = Object.assign(new LdnService(), { + id: 3, + uuid: 3, + name: 'service3', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: pattern1, + }), + Object.assign(new NotifyServicePattern(), { + pattern: pattern2, + }), + ], + }); + + const services = [service1, service2, service3]; + + beforeEach(() => { + ldnServicesService.findByInboundPattern.and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList(services)), + ); + component.ldnServiceByPattern[pattern1.pattern] = { + allowsMultipleRequests: false, + services: [], + } as LdnServiceByPattern; + + component.ldnServiceByPattern[pattern2.pattern] = { + allowsMultipleRequests: false, + services: [], + } as LdnServiceByPattern; + + component.patterns = [pattern1, pattern2]; + + spyOn(component, 'filterServices').and.callFake((pattern) => { + return of(services); + }); + }); + + it('should initialize the selected services by pattern', () => { + component.initSelectedServicesByPattern(); + + expect(component.ldnServiceByPattern[pattern1.pattern].services).toEqual([null]); + expect(component.ldnServiceByPattern[pattern2.pattern].services).toEqual([null]); + }); + + it('should add the service to the selected services by pattern if the section data has a value for the pattern', () => { + component.sectionData.data[pattern1.pattern] = [service1.uuid, service3.uuid]; + component.sectionData.data[pattern2.pattern] = [service2.uuid, service3.uuid]; + component.initSelectedServicesByPattern(); + + expect(component.ldnServiceByPattern[pattern1.pattern].services).toEqual([ + service1, + service3, + ]); + expect(component.ldnServiceByPattern[pattern2.pattern].services).toEqual([ + service2, + service3, + ]); + }); + }); + + describe('addService', () => { + const ldnPattern = { pattern: 'review', multipleRequest: false }; + const service: any = { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [{ pattern: ldnPattern.pattern }], + }; + + beforeEach(() => { + component.ldnServiceByPattern[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [], + } as LdnServiceByPattern; + }); + + it('should push the new service to the array corresponding to the pattern', () => { + component.addService(ldnPattern, service); + + expect(component.ldnServiceByPattern[ldnPattern.pattern].services).toEqual([service]); + }); + }); + + describe('removeService', () => { + const ldnPattern = { pattern: 'review', multipleRequest: false }; + const service1: LdnService = Object.assign(new LdnService(), { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: ldnPattern.pattern, + }), + ], + }); + const service2: LdnService = Object.assign(new LdnService(), { + id: 2, + name: 'service2', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: ldnPattern.pattern, + }), + ], + }); + const service3: LdnService = Object.assign(new LdnService(), { + id: 3, + name: 'service3', + notifyServiceInboundPatterns: [ + Object.assign(new NotifyServicePattern(), { + pattern: ldnPattern.pattern, + }), + ], + }); + + beforeEach(() => { + component.ldnServiceByPattern[ldnPattern.pattern] = { + allowsMultipleRequests: false, + services: [], + } as LdnServiceByPattern; + }); + + + it('should remove the service at the specified index from the array corresponding to the pattern', () => { + component.ldnServiceByPattern[ldnPattern.pattern].services = [service1, service2, service3]; + component.removeService(ldnPattern, 1); + + expect(component.ldnServiceByPattern[ldnPattern.pattern].services).toEqual([ + service1, + service3, + ]); + }); + }); + + describe('filterServices', () => { + const pattern = 'review'; + const service1: any = { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + const service2: any = { + id: 2, + name: 'service2', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + const service3: any = { + id: 3, + name: 'service3', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + const services = [service1, service2, service3]; + + beforeEach(() => { + ldnServicesService.findByInboundPattern.and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList(services)), + ); + }); + + it('should return an observable of the services that match the given pattern', () => { + component.filterServices(pattern).subscribe((result) => { + expect(result).toEqual(services); + }); + }); + }); + + describe('hasInboundPattern', () => { + const pattern = 'review'; + const service: any = { + id: 1, + name: 'service1', + notifyServiceInboundPatterns: [{ pattern: pattern }], + }; + + it('should return true if the service has the specified inbound pattern type', () => { + expect(component.hasInboundPattern(service, pattern)).toBeTrue(); + }); + + it('should return false if the service does not have the specified inbound pattern type', () => { + expect(component.hasInboundPattern(service, 'endorsement')).toBeFalse(); + }); + }); + + describe('getSectionServerErrorsAndSetErrorsToDisplay', () => { + it('should set the validation errors for the current section to display', () => { + const validationErrors = [ + { path: 'sections/sectionId/data/notifyCoar', message: 'error' }, + ]; + sectionService.getSectionServerErrors.and.returnValue( + of(validationErrors), + ); + + componentAsAny.getSectionServerErrorsAndSetErrorsToDisplay(); + + expect(sectionService.setSectionError).toHaveBeenCalledWith( + component.submissionId, + component.sectionData.id, + validationErrors[0], + ); + }); + }); + + describe('onSectionDestroy', () => { + it('should unsubscribe from all subscriptions', () => { + const sub1 = of(null).subscribe(); + const sub2 = of(null).subscribe(); + componentAsAny.subs = [sub1, sub2]; + spyOn(sub1, 'unsubscribe'); + spyOn(sub2, 'unsubscribe'); + component.onSectionDestroy(); + expect(sub1.unsubscribe).toHaveBeenCalled(); + expect(sub2.unsubscribe).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts new file mode 100644 index 00000000000..fc501c3cd9f --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/section-coar-notify.component.ts @@ -0,0 +1,377 @@ +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Inject, +} from '@angular/core'; +import { + NgbDropdown, + NgbDropdownModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + filter, + map, + take, + tap, +} from 'rxjs/operators'; + +import { LdnServicesService } from '../../../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { + LdnService, + LdnServiceByPattern, +} from '../../../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { + getFirstCompletedRemoteData, + getPaginatedListPayload, + getRemoteDataPayload, +} from '../../../core/shared/operators'; +import { + hasValue, + isEmpty, + isNotEmpty, +} from '../../../shared/empty.util'; +import { SubmissionSectionError } from '../../objects/submission-section-error.model'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { CoarNotifyConfigDataService } from './coar-notify-config-data.service'; +import { LdnPattern } from './submission-coar-notify.config'; + +/** + * This component represents a section that contains the submission section-coar-notify form. + */ +@Component({ + selector: 'ds-submission-section-coar-notify', + templateUrl: './section-coar-notify.component.html', + styleUrls: ['./section-coar-notify.component.scss'], + standalone: true, + imports: [ + NgIf, + NgForOf, + AsyncPipe, + TranslateModule, + NgbDropdownModule, + NgClass, + InfiniteScrollModule, + ], + providers: [NgbDropdown], +}) +export class SubmissionSectionCoarNotifyComponent extends SectionModelComponent { + + hasSectionData = false; + /** + * Contains an array of string patterns. + */ + patterns: LdnPattern[] = []; + /** + * An object that maps string keys to arrays of LdnService objects. + * Used to store LdnService objects by pattern. + */ + ldnServiceByPattern: { [key: string]: LdnServiceByPattern } = {}; + /** + * A map representing all services for each pattern + * { + * 'pattern': { + * 'index': 'service.id' + * } + * } + * + * @type {{ [key: string]: {[key: number]: number} }} + * @memberof SubmissionSectionCoarNotifyComponent + */ + previousServices: { [key: string]: LdnServiceByPattern } = {}; + + /** + * The [[JsonPatchOperationPathCombiner]] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + /** + * A map representing all field on their way to be removed + * @type {Map} + */ + protected fieldsOnTheirWayToBeRemoved: Map = new Map(); + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + private filteredServicesByPattern = {}; + + constructor(protected ldnServicesService: LdnServicesService, + // protected formOperationsService: SectionFormOperationsService, + protected operationsBuilder: JsonPatchOperationsBuilder, + protected sectionService: SectionsService, + protected coarNotifyConfigDataService: CoarNotifyConfigDataService, + protected chd: ChangeDetectorRef, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + /** + * Initialize all instance variables + */ + onSectionInit() { + this.setCoarNotifyConfig(); + this.getSectionServerErrorsAndSetErrorsToDisplay(); + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + } + + /** + * Method called when section is initialized + * Retriev available NotifyConfigs + */ + setCoarNotifyConfig() { + this.subs.push( + this.coarNotifyConfigDataService.findAll().pipe( + getFirstCompletedRemoteData(), + ).subscribe((data) => { + if (data.hasSucceeded) { + this.patterns = data.payload.page[0].patterns; + this.initSelectedServicesByPattern(); + } + })); + } + + /** + * Handles the change event of a select element. + * @param pattern - The pattern of the select element. + * @param index - The index of the select element. + * @param selectedService - The selected LDN service. + */ + onChange(pattern: string, index: number, selectedService: LdnService | null) { + // do nothing if the selected value is the same as the previous one + if (this.ldnServiceByPattern[pattern].services[index]?.id === selectedService?.id) { + return; + } + + // initialize the previousServices object for the pattern if it does not exist + if (!this.previousServices[pattern]) { + this.previousServices[pattern] = { + services: [], + allowsMultipleRequests: this.patterns.find(ldnPattern => ldnPattern.pattern === pattern)?.multipleRequest, + }; + } + + // store the previous value + this.previousServices[pattern].services[index] = this.ldnServiceByPattern[pattern].services[index]; + // set the new value + this.ldnServiceByPattern[pattern].services[index] = selectedService; + + const hasPrevValueStored = hasValue(this.previousServices[pattern].services[index]) && this.previousServices[pattern].services[index].id !== selectedService?.id; + if (hasPrevValueStored) { + // when there is a previous value stored and it is different from the new one + this.operationsBuilder.flushOperation(this.pathCombiner.getPath([pattern, '-'])); + if (this.filteredServicesByPattern[pattern]?.includes(this.previousServices[pattern].services[index])){ + this.operationsBuilder.remove(this.pathCombiner.getPath([pattern, index.toString()])); + } + } + + if (!hasPrevValueStored || (selectedService?.id && hasPrevValueStored)) { + // add the path when there is no previous value stored + this.operationsBuilder.add(this.pathCombiner.getPath([pattern, '-']), [selectedService.id], false, true); + } + // set the previous value to the new value + this.previousServices[pattern].services[index] = this.ldnServiceByPattern[pattern].services[index]; + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); + this.chd.detectChanges(); + } + + /** + * Initializes the selected services by pattern. + * Loops through each pattern and filters the services based on the pattern. + * If the section data has a value for the pattern, it adds the service to the selected services by pattern. + * If the section data does not have a value for the pattern, it adds a null service to the selected services by pattern, + * so that the select element is initialized with a null value and to display the default select input. + */ + initSelectedServicesByPattern(): void { + this.patterns.forEach((ldnPattern) => { + if (hasValue(this.sectionData.data[ldnPattern.pattern])) { + this.subs.push( + this.filterServices(ldnPattern.pattern) + .subscribe((services: LdnService[]) => { + + if (!this.ldnServiceByPattern[ldnPattern.pattern]) { + this.ldnServiceByPattern[ldnPattern.pattern] = { + services: [], + allowsMultipleRequests: ldnPattern.multipleRequest, + }; + } + + this.ldnServiceByPattern[ldnPattern.pattern].services = services.filter((service) => { + const selection = (this.sectionData.data[ldnPattern.pattern] as LdnService[]).find((s: LdnService) => s.id === service.id); + this.addService(ldnPattern, selection); + return this.sectionData.data[ldnPattern.pattern].includes(service.uuid); + }); + }), + ); + } else { + this.ldnServiceByPattern[ldnPattern.pattern] = { + services: [], + allowsMultipleRequests: ldnPattern.multipleRequest, + }; + this.addService(ldnPattern, null); + } + }); + } + + /** + * Adds a new service to the selected services for the given pattern. + * @param ldnPattern - The pattern to add the new service to. + * @param newService - The new service to add. + */ + addService(ldnPattern: LdnPattern, newService: LdnService) { + // Your logic to add a new service to the selected services for the pattern + // Example: Push the newService to the array corresponding to the pattern + if (!this.ldnServiceByPattern[ldnPattern.pattern]) { + this.ldnServiceByPattern[ldnPattern.pattern] = { + services: [], + allowsMultipleRequests: ldnPattern.multipleRequest, + }; + } + this.ldnServiceByPattern[ldnPattern.pattern].services.push(newService); + } + + /** + * Removes the service at the specified index from the array corresponding to the pattern. + * @param ldnPattern - The LDN pattern from which to remove the service + * @param serviceIndex - the service index to remove + */ + removeService(ldnPattern: LdnPattern, serviceIndex: number) { + if (this.ldnServiceByPattern[ldnPattern.pattern]) { + // Remove the service at the specified index from the array + this.ldnServiceByPattern[ldnPattern.pattern].services.splice(serviceIndex, 1); + this.previousServices[ldnPattern.pattern]?.services.splice(serviceIndex, 1); + this.operationsBuilder.flushOperation(this.pathCombiner.getPath([ldnPattern.pattern, '-'])); + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); + } + if (!this.ldnServiceByPattern[ldnPattern.pattern].services.length) { + this.addNewService(ldnPattern); + } + } + + /** + * Method called when dropdowns for the section are initialized + * Retrieve services with corresponding patterns to the dropdowns. + */ + filterServices(pattern: string): Observable { + return this.ldnServicesService.findByInboundPattern(pattern).pipe( + getFirstCompletedRemoteData(), + tap((rd) => { + if (rd.hasFailed) { + throw new Error(`Failed to retrieve services for pattern ${pattern}`); + } + }), + filter((rd) => rd.hasSucceeded), + getRemoteDataPayload(), + getPaginatedListPayload(), + tap(res => { + if (!this.filteredServicesByPattern[pattern]){ + this.filteredServicesByPattern[pattern] = []; + } + if (this.filteredServicesByPattern[pattern].length === 0) { + this.filteredServicesByPattern[pattern].push(...res); + } + }), + map((res: LdnService[]) => res.filter((service) => { + if (!this.hasSectionData){ + this.hasSectionData = this.hasInboundPattern(service, pattern); + } + return this.hasInboundPattern(service, pattern); + })), + ); + } + + /** + * Checks if the given service has the specified inbound pattern type. + * @param service - The service to check. + * @param patternType - The inbound pattern type to look for. + * @returns True if the service has the specified inbound pattern type, false otherwise. + */ + hasInboundPattern(service: any, patternType: string): boolean { + return service.notifyServiceInboundPatterns.some((pattern: { pattern: string }) => { + return pattern.pattern === patternType; + }); + } + + /** + * Retrieves server errors for the current section and sets them to display. + * @returns An Observable that emits the validation errors for the current section. + */ + private getSectionServerErrorsAndSetErrorsToDisplay() { + this.subs.push( + this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( + take(1), + filter((validationErrors) => isNotEmpty(validationErrors)), + ).subscribe((validationErrors: SubmissionSectionError[]) => { + if (isNotEmpty(validationErrors)) { + validationErrors.forEach((error) => { + this.sectionService.setSectionError(this.submissionId, this.sectionData.id, error); + }); + } + })); + } + + /** + * Returns an observable of the errors for the current section that match the given pattern and index. + * @param pattern - The pattern to match against the error paths. + * @param index - The index to match against the error paths. + * @returns An observable of the errors for the current section that match the given pattern and index. + */ + public getShownSectionErrors$(pattern: string, index: number): Observable { + return this.sectionService.getShownSectionErrors(this.submissionId, this.sectionData.id, this.sectionData.sectionType) + .pipe( + take(1), + filter((validationErrors) => isNotEmpty(validationErrors)), + map((validationErrors: SubmissionSectionError[]) => { + return validationErrors.filter((error) => { + const path = `${pattern}/${index}`; + return error.path.includes(path); + }); + }), + ); + } + + /** + * @returns An observable that emits a boolean indicating whether the section has any server errors or not. + */ + protected getSectionStatus(): Observable { + return this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( + map((validationErrors) => isEmpty(validationErrors), + )); + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Add new row to dropdown for multiple service selection + * @param ldnPattern - the related LDN pattern where the service is added + */ + addNewService(ldnPattern: LdnPattern): void { + //idle new service for new selection + this.ldnServiceByPattern[ldnPattern.pattern].services.push(null); + } +} diff --git a/src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts b/src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts new file mode 100644 index 00000000000..b80244c2723 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/submission-coar-notify-workspaceitem.model.ts @@ -0,0 +1,39 @@ +import { + autoserialize, + deserialize, + deserializeAs, + inheritSerialization, +} from 'cerialize'; + +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { CacheableObject } from '../../../core/cache/cacheable-object.model'; +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { COAR_NOTIFY_WORKSPACEITEM } from './section-coar-notify-service.resource-type'; + +/** An CoarNotify and its properties. */ +@typedObject +@inheritSerialization(CacheableObject) +export class SubmissionCoarNotifyWorkspaceitemModel extends CacheableObject { + static type = COAR_NOTIFY_WORKSPACEITEM; + + @excludeFromEquals + @autoserialize + endorsement?: number[]; + + @deserializeAs('id') + review?: number[]; + + @autoserialize + ingest?: number[]; + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } +} diff --git a/src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts b/src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts new file mode 100644 index 00000000000..04decc64599 --- /dev/null +++ b/src/app/submission/sections/section-coar-notify/submission-coar-notify.config.ts @@ -0,0 +1,47 @@ +import { + autoserialize, + deserialize, + deserializeAs, + inheritSerialization, +} from 'cerialize'; + +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { CacheableObject } from '../../../core/cache/cacheable-object.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { SUBMISSION_COAR_NOTIFY_CONFIG } from './section-coar-notify-service.resource-type'; + +export interface LdnPattern { + pattern: string, + multipleRequest: boolean +} +/** A SubmissionCoarNotifyConfig and its properties. */ +@typedObject +@inheritSerialization(CacheableObject) +export class SubmissionCoarNotifyConfig extends CacheableObject { + static type = SUBMISSION_COAR_NOTIFY_CONFIG; + + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + id: string; + + @deserializeAs('id') + uuid: string; + + @autoserialize + patterns: LdnPattern[]; + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } +} diff --git a/src/app/submission/sections/sections-decorator.ts b/src/app/submission/sections/sections-decorator.ts index 7e7840adfd0..8e0df1cb239 100644 --- a/src/app/submission/sections/sections-decorator.ts +++ b/src/app/submission/sections/sections-decorator.ts @@ -1,7 +1,29 @@ - +import { SubmissionSectionAccessesComponent } from './accesses/section-accesses.component'; +import { SubmissionSectionCcLicensesComponent } from './cc-license/submission-section-cc-licenses.component'; +import { SubmissionSectionDuplicatesComponent } from './duplicates/section-duplicates.component'; +import { SubmissionSectionFormComponent } from './form/section-form.component'; +import { SubmissionSectionIdentifiersComponent } from './identifiers/section-identifiers.component'; +import { SubmissionSectionLicenseComponent } from './license/section-license.component'; +import { SubmissionSectionCoarNotifyComponent } from './section-coar-notify/section-coar-notify.component'; import { SectionsType } from './sections-type'; +import { SubmissionSectionSherpaPoliciesComponent } from './sherpa-policies/section-sherpa-policies.component'; +import { SubmissionSectionUploadComponent } from './upload/section-upload.component'; const submissionSectionsMap = new Map(); + +submissionSectionsMap.set(SectionsType.AccessesCondition, SubmissionSectionAccessesComponent); +submissionSectionsMap.set(SectionsType.License, SubmissionSectionLicenseComponent); +submissionSectionsMap.set(SectionsType.CcLicense, SubmissionSectionCcLicensesComponent); +submissionSectionsMap.set(SectionsType.SherpaPolicies, SubmissionSectionSherpaPoliciesComponent); +submissionSectionsMap.set(SectionsType.Upload, SubmissionSectionUploadComponent); +submissionSectionsMap.set(SectionsType.SubmissionForm, SubmissionSectionFormComponent); +submissionSectionsMap.set(SectionsType.Identifiers, SubmissionSectionIdentifiersComponent); +submissionSectionsMap.set(SectionsType.CoarNotify, SubmissionSectionCoarNotifyComponent); +submissionSectionsMap.set(SectionsType.Duplicates, SubmissionSectionDuplicatesComponent); + +/** + * @deprecated + */ export function renderSectionFor(sectionType: SectionsType) { return function decorator(objectElement: any) { if (!objectElement) { diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts index 6bca8a72526..60b4cedfdc9 100644 --- a/src/app/submission/sections/sections-type.ts +++ b/src/app/submission/sections/sections-type.ts @@ -4,9 +4,10 @@ export enum SectionsType { Upload = 'upload', License = 'license', CcLicense = 'cclicense', - collection = 'collection', AccessesCondition = 'accessCondition', SherpaPolicies = 'sherpaPolicy', Identifiers = 'identifiers', Collection = 'collection', + CoarNotify = 'coarnotify', + Duplicates = 'duplicates' } diff --git a/src/app/submission/sections/sections.directive.ts b/src/app/submission/sections/sections.directive.ts index de6d43e1eb1..7cf1921ff0e 100644 --- a/src/app/submission/sections/sections.directive.ts +++ b/src/app/submission/sections/sections.directive.ts @@ -1,22 +1,35 @@ -import { ChangeDetectorRef, Directive, Input, OnDestroy, OnInit } from '@angular/core'; - -import { Observable, Subscription } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + ChangeDetectorRef, + Directive, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import uniq from 'lodash/uniq'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { map } from 'rxjs/operators'; -import { SectionsService } from './sections.service'; -import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; -import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; +import { + hasValue, + isNotEmpty, + isNotNull, +} from '../../shared/empty.util'; +import { SubmissionSectionError } from '../objects/submission-section-error.model'; import { SubmissionService } from '../submission.service'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; +import { SectionsService } from './sections.service'; import { SectionsType } from './sections-type'; -import { SubmissionSectionError } from '../objects/submission-section-error.model'; /** * Directive for handling generic section functionality */ @Directive({ selector: '[dsSection]', - exportAs: 'sectionRef' + exportAs: 'sectionRef', + standalone: true, }) export class SectionsDirective implements OnDestroy, OnInit { @@ -140,7 +153,7 @@ export class SectionsDirective implements OnDestroy, OnInit { this.submissionService.dispatchSave(this.submissionId); } } - }) + }), ); this.enabled = this.sectionService.isSectionEnabled(this.submissionId, this.sectionId); diff --git a/src/app/submission/sections/sections.service.spec.ts b/src/app/submission/sections/sections.service.spec.ts index 5aa47d1447b..0241564ab73 100644 --- a/src/app/submission/sections/sections.service.spec.ts +++ b/src/app/submission/sections/sections.service.spec.ts @@ -1,42 +1,55 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { cold, getTestScheduler } from 'jasmine-marbles'; -import { of as observableOf } from 'rxjs'; -import { Store, StoreModule } from '@ngrx/store'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; -import { submissionReducers } from '../submission.reducers'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SubmissionService } from '../submission.service'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { SectionsService } from './sections.service'; +import { storeModuleConfig } from '../../app.reducer'; +import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; +import { FormClearErrorsAction } from '../../shared/form/form.actions'; +import { FormService } from '../../shared/form/form.service'; +import { getMockFormService } from '../../shared/mocks/form-service.mock'; +import { getMockScrollToService } from '../../shared/mocks/scroll-to-service.mock'; import { mockSectionsData, mockSectionsErrors, mockSubmissionState, - mockSubmissionStateWithoutUpload + mockSubmissionStateWithoutUpload, } from '../../shared/mocks/submission.mock'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; +import { SectionScope } from '../objects/section-visibility.model'; import { DisableSectionAction, EnableSectionAction, InertSectionErrorsAction, RemoveSectionErrorsAction, SectionStatusChangeAction, - UpdateSectionDataAction + UpdateSectionDataAction, } from '../objects/submission-objects.actions'; -import { FormClearErrorsAction } from '../../shared/form/form.actions'; +import { SubmissionSectionError } from '../objects/submission-section-error.model'; +import { submissionReducers } from '../submission.reducers'; +import { SubmissionService } from '../submission.service'; import parseSectionErrors from '../utils/parseSectionErrors'; -import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; -import { getMockScrollToService } from '../../shared/mocks/scroll-to-service.mock'; -import { storeModuleConfig } from '../../app.reducer'; +import { SectionsService } from './sections.service'; import { SectionsType } from './sections-type'; -import { FormService } from '../../shared/form/form.service'; -import { getMockFormService } from '../../shared/mocks/form-service.mock'; -import { SubmissionSectionError } from '../objects/submission-section-error.model'; describe('SectionsService test suite', () => { let notificationsServiceStub: NotificationsServiceStub; @@ -56,7 +69,7 @@ describe('SectionsService test suite', () => { const store: any = jasmine.createSpyObj('store', { dispatch: jasmine.createSpy('dispatch'), - select: jasmine.createSpy('select') + select: jasmine.createSpy('select'), }); const formService: any = getMockFormService(); @@ -68,9 +81,9 @@ describe('SectionsService test suite', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }) + useClass: TranslateLoaderMock, + }, + }), ], providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, @@ -79,8 +92,8 @@ describe('SectionsService test suite', () => { { provide: TranslateService, useValue: getMockTranslateService() }, { provide: Store, useValue: store }, { provide: FormService, useValue: formService }, - SectionsService - ] + SectionsService, + ], }).compileComponents(); })); @@ -161,7 +174,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(sectionData[sectionId])); const expected = cold('(b|)', { - b: sectionData[sectionId] + b: sectionData[sectionId], }); expect(service.getSectionData(submissionId, sectionId, SectionsType.SubmissionForm)).toBeObservable(expected); @@ -173,7 +186,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(sectionErrors[sectionId])); const expected = cold('(b|)', { - b: sectionErrors[sectionId] + b: sectionErrors[sectionId], }); expect(service.getSectionErrors(submissionId, sectionId)).toBeObservable(expected); @@ -185,7 +198,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(sectionState)); const expected = cold('(b|)', { - b: sectionState + b: sectionState, }); expect(service.getSectionState(submissionId, sectionId, SectionsType.SubmissionForm)).toBeObservable(expected); @@ -197,7 +210,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf({ isValid: false })); let expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionValid(submissionId, sectionId)).toBeObservable(expected); @@ -205,7 +218,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf({ isValid: true })); expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSectionValid(submissionId, sectionId)).toBeObservable(expected); @@ -217,7 +230,7 @@ describe('SectionsService test suite', () => { submissionServiceStub.getActiveSectionId.and.returnValue(observableOf(sectionId)); let expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSectionActive(submissionId, sectionId)).toBeObservable(expected); @@ -225,7 +238,7 @@ describe('SectionsService test suite', () => { submissionServiceStub.getActiveSectionId.and.returnValue(observableOf('test')); expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionActive(submissionId, sectionId)).toBeObservable(expected); @@ -237,7 +250,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf({ enabled: false })); let expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionEnabled(submissionId, sectionId)).toBeObservable(expected); @@ -245,7 +258,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf({ enabled: true })); expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSectionEnabled(submissionId, sectionId)).toBeObservable(expected); @@ -253,46 +266,282 @@ describe('SectionsService test suite', () => { }); describe('isSectionReadOnly', () => { - it('should return an observable of true when it\'s a readonly section and scope is not workspace', () => { - store.select.and.returnValue(observableOf({ - visibility: { - main: null, - other: 'READONLY' - } - })); + describe('when submission scope is workspace', () => { + describe('and section scope is workspace', () => { + it('should return an observable of true when visibility main is READONLY and visibility other is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: { + main: 'READONLY', + other: null, + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + it('should return an observable of true when both visibility main and other are READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: { + main: 'READONLY', + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + it('should return an observable of false when visibility main is null and visibility other is READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: { + main: null, + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + it('should return an observable of false when visibility is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: null, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); - const expected = cold('(b|)', { - b: true }); - expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); - }); - - it('should return an observable of false when it\'s a readonly section and scope is workspace', () => { - store.select.and.returnValue(observableOf({ - visibility: { - main: null, - other: 'READONLY' - } - })); + describe('and section scope is workflow', () => { + it('should return an observable of false when visibility main is READONLY and visibility other is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: { + main: 'READONLY', + other: null, + }, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + it('should return an observable of true when both visibility main and other are READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: { + main: 'READONLY', + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + it('should return an observable of true when visibility main is null and visibility other is READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: { + main: null, + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + it('should return an observable of false when visibility is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: null, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); - const expected = cold('(b|)', { - b: false }); - expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + describe('and section scope is null', () => { + it('should return an observable of false', () => { + store.select.and.returnValue(observableOf({ + scope: null, + visibility: null, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + }); }); - it('should return an observable of false when it\'s not a readonly section', () => { - store.select.and.returnValue(observableOf({ - visibility: null - })); + describe('when submission scope is workflow', () => { + describe('and section scope is workspace', () => { + it('should return an observable of false when visibility main is READONLY and visibility other is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: { + main: 'READONLY', + other: null, + }, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + it('should return an observable of true when both visibility main and other are READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: { + main: 'READONLY', + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + it('should return an observable of true when visibility main is null and visibility other is READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: { + main: null, + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + it('should return an observable of false when visibility is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Submission, + visibility: null, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); - const expected = cold('(b|)', { - b: false }); - expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + describe('and section scope is workflow', () => { + it('should return an observable of true when visibility main is READONLY and visibility other is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: { + main: 'READONLY', + other: null, + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + it('should return an observable of true when both visibility main and other is READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: { + main: 'READONLY', + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: true, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + it('should return an observable of false when visibility main is null and visibility other is READONLY', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: { + main: null, + other: 'READONLY', + }, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + it('should return an observable of false when visibility is null', () => { + store.select.and.returnValue(observableOf({ + scope: SectionScope.Workflow, + visibility: null, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + + }); + + describe('and section scope is null', () => { + it('should return an observable of false', () => { + store.select.and.returnValue(observableOf({ + scope: null, + visibility: null, + })); + + const expected = cold('(b|)', { + b: false, + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + }); }); }); @@ -301,7 +550,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionState)); const expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSectionAvailable(submissionId, sectionId)).toBeObservable(expected); @@ -311,7 +560,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionState)); const expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionAvailable(submissionId, 'test')).toBeObservable(expected); @@ -323,7 +572,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionState)); const expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSectionTypeAvailable(submissionId, SectionsType.Upload)).toBeObservable(expected); @@ -333,7 +582,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionStateWithoutUpload)); const expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionAvailable(submissionId, SectionsType.Upload)).toBeObservable(expected); @@ -345,7 +594,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionState)); const expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSectionType(submissionId, 'upload', SectionsType.Upload)).toBeObservable(expected); @@ -355,7 +604,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionState)); const expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionType(submissionId, sectionId, SectionsType.Upload)).toBeObservable(expected); @@ -365,7 +614,7 @@ describe('SectionsService test suite', () => { store.select.and.returnValue(observableOf(submissionState)); const expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSectionType(submissionId, 'no-such-id', SectionsType.Upload)).toBeObservable(expected); @@ -396,7 +645,7 @@ describe('SectionsService test suite', () => { const error: SubmissionSectionError = { path: 'test', - message: 'message test' + message: 'message test', }; service.setSectionError(submissionId, sectionId, error); @@ -446,10 +695,10 @@ describe('SectionsService test suite', () => { rows: [{ fields: [{ selectableMetadata: [{ - metadata: 'dc.contributor.author' - }] - }] - }] + metadata: 'dc.contributor.author', + }], + }], + }], }; const expectedConfiguredMetadata = [ 'dc.contributor.author' ]; diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 0ea62322370..034484581af 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -1,16 +1,42 @@ import { Injectable } from '@angular/core'; - -import { combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, take } from 'rxjs/operators'; +import { parseReviver } from '@ng-dynamic-forms/core'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; +import { + ScrollToConfigOptions, + ScrollToService, +} from '@nicky-lenaers/ngx-scroll-to'; import findIndex from 'lodash/findIndex'; import findKey from 'lodash/findKey'; import isEqual from 'lodash/isEqual'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + mergeMap, + take, +} from 'rxjs/operators'; -import { SubmissionState } from '../submission.reducers'; -import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { SubmissionFormsModel } from '../../core/config/models/config-submission-forms.model'; +import { JsonPatchOperationPathCombiner } from '../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; +import { normalizeSectionData } from '../../core/submission/submission-response-parsing.service'; +import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; +import { + hasValue, + isEmpty, + isNotEmpty, + isNotUndefined, +} from '../../shared/empty.util'; +import { FormClearErrorsAction } from '../../shared/form/form.actions'; +import { FormError } from '../../shared/form/form.reducer'; +import { FormService } from '../../shared/form/form.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SectionScope } from '../objects/section-visibility.model'; import { DisableSectionAction, EnableSectionAction, @@ -18,38 +44,27 @@ import { RemoveSectionErrorsAction, SectionStatusChangeAction, SetSectionFormId, - UpdateSectionDataAction + UpdateSectionDataAction, } from '../objects/submission-objects.actions'; -import { - SubmissionObjectEntry -} from '../objects/submission-objects.reducer'; +import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; +import { SubmissionSectionError } from '../objects/submission-section-error.model'; +import { SubmissionSectionObject } from '../objects/submission-section-object.model'; import { submissionObjectFromIdSelector, submissionSectionDataFromIdSelector, submissionSectionErrorsFromIdSelector, submissionSectionFromIdSelector, - submissionSectionServerErrorsFromIdSelector + submissionSectionServerErrorsFromIdSelector, } from '../selectors'; -import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; -import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; -import { FormClearErrorsAction } from '../../shared/form/form.actions'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionState } from '../submission.reducers'; import { SubmissionService } from '../submission.service'; -import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; import { SectionsType } from './sections-type'; -import { normalizeSectionData } from '../../core/submission/submission-response-parsing.service'; -import { SubmissionFormsModel } from '../../core/config/models/config-submission-forms.model'; -import { parseReviver } from '@ng-dynamic-forms/core'; -import { FormService } from '../../shared/form/form.service'; -import { JsonPatchOperationPathCombiner } from '../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { FormError } from '../../shared/form/form.reducer'; -import { SubmissionSectionObject } from '../objects/submission-section-object.model'; -import { SubmissionSectionError } from '../objects/submission-section-error.model'; /** * A service that provides methods used in submission process. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class SectionsService { /** @@ -197,12 +212,12 @@ export class SectionsService { const sectionErrors = formErrors .map((error) => ({ path: pathCombiner.getPath(error.fieldId.replace(/\_/g, '.')).path, - message: error.message + message: error.message, } as SubmissionSectionError)) .filter((sectionError: SubmissionSectionError) => findIndex(state.errorsToShow, { path: sectionError.path }) === -1); return [...state.errorsToShow, ...sectionErrors]; - }) - )) + }), + )), ); } @@ -257,13 +272,13 @@ export class SectionsService { map((sectionState: SubmissionSectionObject) => { if (hasValue(sectionState.data) && sectionType === SectionsType.SubmissionForm) { return Object.assign({}, sectionState, { - data: normalizeSectionData(sectionState.data) + data: normalizeSectionData(sectionState.data), }); } else { return sectionState; } }), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -333,10 +348,14 @@ export class SectionsService { return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( filter((sectionObj) => hasValue(sectionObj)), map((sectionObj: SubmissionSectionObject) => { - return isNotEmpty(sectionObj.visibility) - && ((sectionObj.visibility.other === 'READONLY' && submissionScope !== SubmissionScopeType.WorkspaceItem) - || (sectionObj.visibility.main === 'READONLY' && submissionScope === SubmissionScopeType.WorkspaceItem) - ); + if (isEmpty(submissionScope) || isEmpty(sectionObj.visibility) || isEmpty(sectionObj.scope)) { + return false; + } + const convertedSubmissionScope: SectionScope = submissionScope.valueOf() === SubmissionScopeType.WorkspaceItem.valueOf() ? + SectionScope.Submission : SectionScope.Workflow; + const visibility = convertedSubmissionScope.valueOf() === sectionObj.scope.valueOf() ? + sectionObj.visibility.main : sectionObj.visibility.other; + return visibility === 'READONLY'; }), distinctUntilChanged()); } @@ -407,7 +426,7 @@ export class SectionsService { this.store.dispatch(new EnableSectionAction(submissionId, sectionId)); const config: ScrollToConfigOptions = { target: sectionId, - offset: -70 + offset: -70, }; this.scrollToService.scrollTo(config); @@ -449,7 +468,7 @@ export class SectionsService { data: WorkspaceitemSectionDataType, errorsToShow: SubmissionSectionError[] = [], serverValidationErrors: SubmissionSectionError[] = [], - metadata?: string[] + metadata?: string[], ) { if (isNotEmpty(data)) { const isAvailable$ = this.isSectionAvailable(submissionId, sectionId); diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts index b65cb5e00fe..2abe8e02ec6 100644 --- a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts +++ b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts @@ -1,13 +1,18 @@ -import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ContentAccordionComponent } from './content-accordion.component'; - import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; +import { ContentAccordionComponent } from './content-accordion.component'; describe('ContentAccordionComponent', () => { let component: ContentAccordionComponent; @@ -20,12 +25,12 @@ describe('ContentAccordionComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), - NgbCollapseModule + NgbCollapseModule, + ContentAccordionComponent, ], - declarations: [ContentAccordionComponent] }) .compileComponents(); }); diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts index 0e7bc863ad3..2fde4f37cd4 100644 --- a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts +++ b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts @@ -1,4 +1,14 @@ -import { Component, Input } from '@angular/core'; +import { + NgForOf, + NgIf, + TitleCasePipe, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; import { PermittedVersions } from '../../../../core/submission/models/sherpa-policies-details.model'; @@ -8,7 +18,15 @@ import { PermittedVersions } from '../../../../core/submission/models/sherpa-pol @Component({ selector: 'ds-content-accordion', templateUrl: './content-accordion.component.html', - styleUrls: ['./content-accordion.component.scss'] + styleUrls: ['./content-accordion.component.scss'], + imports: [ + NgForOf, + TranslateModule, + NgIf, + NgbCollapseModule, + TitleCasePipe, + ], + standalone: true, }) export class ContentAccordionComponent { /** diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts index 9a60a6d0106..5accbf3c5ff 100644 --- a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts +++ b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts @@ -1,12 +1,17 @@ -import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MetadataInformationComponent } from './metadata-information.component'; - import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { MetadataInformationComponent } from './metadata-information.component'; describe('MetadataInformationComponent', () => { let component: MetadataInformationComponent; @@ -19,11 +24,11 @@ describe('MetadataInformationComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), + MetadataInformationComponent, ], - declarations: [MetadataInformationComponent] }) .compileComponents(); }); diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts index 307c18d8ccf..3c30038a9ae 100644 --- a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts +++ b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts @@ -1,4 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { + DatePipe, + NgIf, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { Metadata } from '../../../../core/submission/models/sherpa-policies-details.model'; @@ -8,7 +16,13 @@ import { Metadata } from '../../../../core/submission/models/sherpa-policies-det @Component({ selector: 'ds-metadata-information', templateUrl: './metadata-information.component.html', - styleUrls: ['./metadata-information.component.scss'] + styleUrls: ['./metadata-information.component.scss'], + imports: [ + NgIf, + TranslateModule, + DatePipe, + ], + standalone: true, }) export class MetadataInformationComponent { /** diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts index c5dc896858f..fae68cd8a48 100644 --- a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts +++ b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts @@ -1,11 +1,17 @@ -import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PublicationInformationComponent } from './publication-information.component'; import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { PublicationInformationComponent } from './publication-information.component'; describe('PublicationInformationComponent', () => { let component: PublicationInformationComponent; @@ -19,11 +25,11 @@ describe('PublicationInformationComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), + PublicationInformationComponent, ], - declarations: [PublicationInformationComponent] }) .compileComponents(); }); diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts index cfe42adf7bc..8f256700a07 100644 --- a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts +++ b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts @@ -1,4 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { Journal } from '../../../../core/submission/models/sherpa-policies-details.model'; @@ -8,7 +16,13 @@ import { Journal } from '../../../../core/submission/models/sherpa-policies-deta @Component({ selector: 'ds-publication-information', templateUrl: './publication-information.component.html', - styleUrls: ['./publication-information.component.scss'] + styleUrls: ['./publication-information.component.scss'], + imports: [ + NgIf, + TranslateModule, + NgForOf, + ], + standalone: true, }) export class PublicationInformationComponent { /** diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts index 3e2c33481ab..773c416f1c8 100644 --- a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts +++ b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts @@ -1,11 +1,18 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PublisherPolicyComponent } from './publisher-policy.component'; -import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; +import { ContentAccordionComponent } from '../content-accordion/content-accordion.component'; +import { PublisherPolicyComponent } from './publisher-policy.component'; describe('PublisherPolicyComponent', () => { let component: PublisherPolicyComponent; @@ -18,12 +25,17 @@ describe('PublisherPolicyComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), + PublisherPolicyComponent, ], - declarations: [PublisherPolicyComponent], }) + .overrideComponent(PublisherPolicyComponent, { + remove: { + imports: [ContentAccordionComponent], + }, + }) .compileComponents(); }); diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts index 25407f5a7bb..6852a32473a 100644 --- a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts +++ b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts @@ -1,7 +1,17 @@ -import { Component, Input } from '@angular/core'; +import { + KeyValuePipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { Policy } from '../../../../core/submission/models/sherpa-policies-details.model'; import { AlertType } from '../../../../shared/alert/alert-type'; +import { ContentAccordionComponent } from '../content-accordion/content-accordion.component'; /** * This component represents a section that contains the publisher policy informations. @@ -9,7 +19,15 @@ import { AlertType } from '../../../../shared/alert/alert-type'; @Component({ selector: 'ds-publisher-policy', templateUrl: './publisher-policy.component.html', - styleUrls: ['./publisher-policy.component.scss'] + styleUrls: ['./publisher-policy.component.scss'], + imports: [ + ContentAccordionComponent, + TranslateModule, + KeyValuePipe, + NgForOf, + NgIf, + ], + standalone: true, }) export class PublisherPolicyComponent { diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts index 76a980ed3c7..5882a277e63 100644 --- a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts +++ b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts @@ -1,23 +1,36 @@ -import { SharedModule } from '../../../shared/shared.module'; +import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, +} from '@angular/core/testing'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { SherpaDataResponse } from '../../../shared/mocks/section-sherpa-policies.service.mock'; -import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; - -import { SectionsService } from '../sections.service'; -import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { BrowserModule, By } from '@angular/platform-browser'; - import { Store } from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; import { AppState } from '../../../app.reducer'; -import { SubmissionSectionSherpaPoliciesComponent } from './section-sherpa-policies.component'; -import { SubmissionService } from '../../submission.service'; -import { DebugElement } from '@angular/core'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { SherpaDataResponse } from '../../../shared/mocks/section-sherpa-policies.service.mock'; import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; -import { of as observableOf } from 'rxjs'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { SubmissionService } from '../../submission.service'; +import { SectionsService } from '../sections.service'; +import { MetadataInformationComponent } from './metadata-information/metadata-information.component'; +import { PublicationInformationComponent } from './publication-information/publication-information.component'; +import { PublisherPolicyComponent } from './publisher-policy/publisher-policy.component'; +import { SubmissionSectionSherpaPoliciesComponent } from './section-sherpa-policies.component'; describe('SubmissionSectionSherpaPoliciesComponent', () => { let component: SubmissionSectionSherpaPoliciesComponent; @@ -45,7 +58,7 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, }; describe('SubmissionSectionSherpaPoliciesComponent', () => { @@ -58,13 +71,12 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), NgbCollapseModule, - SharedModule + SubmissionSectionSherpaPoliciesComponent, ], - declarations: [SubmissionSectionSherpaPoliciesComponent], providers: [ { provide: SectionsService, useValue: sectionsServiceStub }, { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, @@ -72,8 +84,17 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { { provide: Store, useValue: storeStub }, { provide: 'sectionDataProvider', useValue: sectionData }, { provide: 'submissionIdProvider', useValue: '1508' }, - ] + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + ], }) + .overrideComponent(SubmissionSectionSherpaPoliciesComponent, { + remove: { imports: [ + MetadataInformationComponent, + AlertComponent, + PublisherPolicyComponent, + PublicationInformationComponent, + ] }, + }) .compileComponents(); }); diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts index eb273a84202..dd614abef6c 100644 --- a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts +++ b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts @@ -1,20 +1,38 @@ -import { AlertType } from '../../../shared/alert/alert-type'; -import { Component, Inject } from '@angular/core'; - -import { BehaviorSubject, Observable, of, Subscription } from 'rxjs'; +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Inject, +} from '@angular/core'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + of, + Subscription, +} from 'rxjs'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { WorkspaceitemSectionSherpaPoliciesObject } from '../../../core/submission/models/workspaceitem-section-sherpa-policies.model'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { AlertType } from '../../../shared/alert/alert-type'; import { - WorkspaceitemSectionSherpaPoliciesObject -} from '../../../core/submission/models/workspaceitem-section-sherpa-policies.model'; -import { renderSectionFor } from '../sections-decorator'; -import { SectionsType } from '../sections-type'; + hasValue, + isEmpty, +} from '../../../shared/empty.util'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { SubmissionService } from '../../submission.service'; +import { SectionModelComponent } from '../models/section.model'; import { SectionDataObject } from '../models/section-data.model'; import { SectionsService } from '../sections.service'; -import { SectionModelComponent } from '../models/section.model'; -import { SubmissionService } from '../../submission.service'; -import { hasValue, isEmpty } from '../../../shared/empty.util'; +import { MetadataInformationComponent } from './metadata-information/metadata-information.component'; +import { PublicationInformationComponent } from './publication-information/publication-information.component'; +import { PublisherPolicyComponent } from './publisher-policy/publisher-policy.component'; /** * This component represents a section for the sherpa policy informations structure. @@ -22,9 +40,21 @@ import { hasValue, isEmpty } from '../../../shared/empty.util'; @Component({ selector: 'ds-section-sherpa-policies', templateUrl: './section-sherpa-policies.component.html', - styleUrls: ['./section-sherpa-policies.component.scss'] + styleUrls: ['./section-sherpa-policies.component.scss'], + imports: [ + MetadataInformationComponent, + NgbCollapseModule, + AlertComponent, + TranslateModule, + PublisherPolicyComponent, + NgIf, + PublicationInformationComponent, + AsyncPipe, + VarDirective, + NgForOf, + ], + standalone: true, }) -@renderSectionFor(SectionsType.SherpaPolicies) export class SubmissionSectionSherpaPoliciesComponent extends SectionModelComponent { /** @@ -95,7 +125,7 @@ export class SubmissionSectionSherpaPoliciesComponent extends SectionModelCompon this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) .subscribe((sherpaPolicies: WorkspaceitemSectionSherpaPoliciesObject) => { this.sherpaPoliciesData$.next(sherpaPolicies); - }) + }), ); } diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html index 7c5a979eed9..f217abf88e3 100644 --- a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html @@ -1,8 +1,8 @@ - + {{accessCondition.name}} {{accessCondition.startDate}} {{accessCondition.endDate}} - {{accessCondition.name}} from {{accessCondition.endDate}} - {{accessCondition.name}} until {{accessCondition.startDate}} + {{accessCondition.name}} from {{accessCondition.endDate}} + {{accessCondition.name}} until {{accessCondition.startDate}}
diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts index 1a6a05e7b69..84eff58f723 100644 --- a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts @@ -1,13 +1,20 @@ -import { Component, Input, OnInit } from '@angular/core'; - -import { find } from 'rxjs/operators'; +import { + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from '../../../../core/data/remote-data'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { Group } from '../../../../core/eperson/models/group.model'; import { ResourcePolicy } from '../../../../core/resource-policy/models/resource-policy.model'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { isEmpty } from '../../../../shared/empty.util'; -import { Group } from '../../../../core/eperson/models/group.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; /** * This component represents a badge that describe an access condition @@ -15,6 +22,11 @@ import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-submission-section-upload-access-conditions', templateUrl: './submission-section-upload-access-conditions.component.html', + imports: [ + NgForOf, + NgIf, + ], + standalone: true, }) export class SubmissionSectionUploadAccessConditionsComponent implements OnInit { @@ -43,13 +55,15 @@ export class SubmissionSectionUploadAccessConditionsComponent implements OnInit this.accessConditions.forEach((accessCondition: ResourcePolicy) => { if (isEmpty(accessCondition.name)) { this.groupService.findByHref(accessCondition._links.group.href).pipe( - find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded)) - .subscribe((rd: RemoteData) => { + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { const group: Group = rd.payload; const accessConditionEntry = Object.assign({}, accessCondition); accessConditionEntry.name = this.dsoNameService.getName(group); this.accessConditionsList.push(accessConditionEntry); - }); + } + }); } else { this.accessConditionsList.push(accessCondition); } diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html index 2baa6c1555a..b03504c6beb 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html @@ -1,12 +1,11 @@
diff --git a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts index dddc6cdc363..1ff35abc48e 100644 --- a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts +++ b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts @@ -1,14 +1,23 @@ -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; - +import { + ChangeDetectionStrategy, + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { mockUploadFiles } from '../../../../../shared/mocks/submission.mock'; -import { FormComponent } from '../../../../../shared/form/form.component'; -import { SubmissionSectionUploadFileViewComponent } from './section-upload-file-view.component'; -import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; import { Metadata } from '../../../../../core/shared/metadata.utils'; +import { FormComponent } from '../../../../../shared/form/form.component'; +import { mockUploadFiles } from '../../../../../shared/mocks/submission.mock'; import { createTestComponent } from '../../../../../shared/testing/utils.test'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { SubmissionSectionUploadAccessConditionsComponent } from '../../accessConditions/submission-section-upload-access-conditions.component'; +import { SubmissionSectionUploadFileViewComponent } from './section-upload-file-view.component'; describe('SubmissionSectionUploadFileViewComponent test suite', () => { @@ -18,22 +27,29 @@ describe('SubmissionSectionUploadFileViewComponent test suite', () => { const fileData: any = mockUploadFiles[0]; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + beforeEach(waitForAsync(async () => { + await TestBed.configureTestingModule({ imports: [ - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), TruncatePipe, FormComponent, SubmissionSectionUploadFileViewComponent, - TestComponent + TestComponent, ], providers: [ - SubmissionSectionUploadFileViewComponent + SubmissionSectionUploadFileViewComponent, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents().then(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(SubmissionSectionUploadFileViewComponent, { + remove: { + imports: [SubmissionSectionUploadAccessConditionsComponent], + }, + add: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents().then(); })); describe('', () => { @@ -92,7 +108,8 @@ describe('SubmissionSectionUploadFileViewComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, }) class TestComponent { diff --git a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts index b15b0ab3211..f065fc9e190 100644 --- a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts +++ b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts @@ -1,9 +1,24 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + MetadataMap, + MetadataValue, +} from '../../../../../core/shared/metadata.models'; +import { Metadata } from '../../../../../core/shared/metadata.utils'; import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { isNotEmpty } from '../../../../../shared/empty.util'; -import { Metadata } from '../../../../../core/shared/metadata.utils'; -import { MetadataMap, MetadataValue } from '../../../../../core/shared/metadata.models'; +import { FileSizePipe } from '../../../../../shared/utils/file-size-pipe'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { SubmissionSectionUploadAccessConditionsComponent } from '../../accessConditions/submission-section-upload-access-conditions.component'; /** * This component allow to show bitstream's metadata @@ -11,6 +26,15 @@ import { MetadataMap, MetadataValue } from '../../../../../core/shared/metadata. @Component({ selector: 'ds-submission-section-upload-file-view', templateUrl: './section-upload-file-view.component.html', + imports: [ + SubmissionSectionUploadAccessConditionsComponent, + TranslateModule, + TruncatePipe, + NgIf, + NgForOf, + FileSizePipe, + ], + standalone: true, }) export class SubmissionSectionUploadFileViewComponent implements OnInit { diff --git a/src/app/submission/sections/upload/section-upload-constants.ts b/src/app/submission/sections/upload/section-upload-constants.ts new file mode 100644 index 00000000000..26f35a094df --- /dev/null +++ b/src/app/submission/sections/upload/section-upload-constants.ts @@ -0,0 +1,2 @@ +export const POLICY_DEFAULT_NO_LIST = 1; // Banner1 +export const POLICY_DEFAULT_WITH_LIST = 2; // Banner2 diff --git a/src/app/submission/sections/upload/section-upload.component.html b/src/app/submission/sections/upload/section-upload.component.html index b57b4542885..9d916a4f982 100644 --- a/src/app/submission/sections/upload/section-upload.component.html +++ b/src/app/submission/sections/upload/section-upload.component.html @@ -2,15 +2,7 @@ [dismissible]="true" [type]="AlertTypeEnum.Info"> - -
-
-

{{'submission.sections.upload.no-file-uploaded' | translate}}

-
-
-
- - +
@@ -26,18 +18,28 @@

{{'submission.sections.upload.n

- - - +
+ {{ 'bitstream.edit.form.primaryBitstream.label' | translate }} +
+
+
+
+
+
+
+ + + [submissionId]="submissionId">

@@ -45,3 +47,11 @@

{{'submission.sections.upload.n

+ + +
+
+
{{'submission.sections.upload.no-file-uploaded' | translate}}
+
+
+
diff --git a/src/app/submission/sections/upload/section-upload.component.spec.ts b/src/app/submission/sections/upload/section-upload.component.spec.ts index 068fc5c7660..61db6c68851 100644 --- a/src/app/submission/sections/upload/section-upload.component.spec.ts +++ b/src/app/submission/sections/upload/section-upload.component.spec.ts @@ -1,22 +1,33 @@ -import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; -import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; - +import { + ChangeDetectorRef, + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { createTestComponent } from '../../../shared/testing/utils.test'; -import { SubmissionObjectState } from '../../objects/submission-objects.reducer'; -import { SubmissionService } from '../../submission.service'; -import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { SectionsService } from '../sections.service'; -import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; +import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; -import { SectionDataObject } from '../models/section-data.model'; -import { SectionsType } from '../sections-type'; +import { SubmissionUploadsConfigDataService } from '../../../core/config/submission-uploads-config-data.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; +import { Group } from '../../../core/eperson/models/group.model'; +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { ResourcePolicyDataService } from '../../../core/resource-policy/resource-policy-data.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { getMockSectionUploadService } from '../../../shared/mocks/section-upload.service.mock'; import { mockGroup, mockSubmissionCollectionId, @@ -25,20 +36,21 @@ import { mockUploadConfigResponse, mockUploadConfigResponseNotRequired, mockUploadFiles, + mockUploadFilesData, } from '../../../shared/mocks/submission.mock'; -import { SubmissionUploadsConfigDataService } from '../../../core/config/submission-uploads-config-data.service'; -import { SectionUploadService } from './section-upload.service'; +import { getMockThemeService } from '../../../shared/mocks/theme-service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ThemeService } from '../../../shared/theme-support/theme.service'; +import { SubmissionObjectState } from '../../objects/submission-objects.reducer'; +import { SubmissionService } from '../../submission.service'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SectionsType } from '../sections-type'; import { SubmissionSectionUploadComponent } from './section-upload.component'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { Collection } from '../../../core/shared/collection.model'; -import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; -import { ResourcePolicyDataService } from '../../../core/resource-policy/resource-policy-data.service'; -import { Group } from '../../../core/eperson/models/group.model'; -import { getMockSectionUploadService } from '../../../shared/mocks/section-upload.service.mock'; -import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; -import { buildPaginatedList } from '../../../core/data/paginated-list.model'; -import { PageInfo } from '../../../core/shared/page-info.model'; +import { SectionUploadService } from './section-upload.service'; function getMockSubmissionUploadsConfigService(): SubmissionFormsConfigDataService { return jasmine.createSpyObj('SubmissionUploadsConfigService', { @@ -46,13 +58,13 @@ function getMockSubmissionUploadsConfigService(): SubmissionFormsConfigDataServi getConfigByHref: jasmine.createSpy('getConfigByHref'), getConfigByName: jasmine.createSpy('getConfigByName'), getConfigBySearch: jasmine.createSpy('getConfigBySearch'), - findByHref: jasmine.createSpy('findByHref') + findByHref: jasmine.createSpy('findByHref'), }); } function getMockCollectionDataService(): CollectionDataService { return jasmine.createSpyObj('CollectionDataService', { - findById: jasmine.createSpy('findById') + findById: jasmine.createSpy('findById'), }); } @@ -65,7 +77,7 @@ function getMockGroupEpersonService(): GroupDataService { function getMockResourcePolicyService(): ResourcePolicyDataService { return jasmine.createSpyObj('ResourcePolicyService', { - findByHref: jasmine.createSpy('findByHref') + findByHref: jasmine.createSpy('findByHref'), }); } @@ -96,13 +108,13 @@ describe('SubmissionSectionUploadComponent test suite', () => { config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', mandatory: true, data: { - files: [] + files: [], }, errorsToShow: [], serverValidationErrors: [], header: 'submit.progressbar.describe.upload', id: 'upload-id', - sectionType: SectionsType.Upload + sectionType: SectionsType.Upload, }; submissionId = mockSubmissionId; collectionId = mockSubmissionCollectionId; @@ -114,18 +126,18 @@ describe('SubmissionSectionUploadComponent test suite', () => { { key: 'dc.title', language: 'en_US', - value: 'Community 1-Collection 1' + value: 'Community 1-Collection 1', }], _links: { - defaultAccessConditions: collectionId + '/defaultAccessConditions' - } + defaultAccessConditions: collectionId + '/defaultAccessConditions', + }, }); mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { name: null, groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', id: 20, - uuid: 'resource-policy-20' + uuid: 'resource-policy-20', }); uploadsConfigService = getMockSubmissionUploadsConfigService(); @@ -145,32 +157,30 @@ describe('SubmissionSectionUploadComponent test suite', () => { submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new Collection(), mockCollection, { - defaultAccessConditions: createSuccessfulRemoteDataObject$(mockDefaultAccessCondition) + defaultAccessConditions: createSuccessfulRemoteDataObject$(mockDefaultAccessCondition), }))); resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); uploadsConfigService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$( - buildPaginatedList(new PageInfo(), [mockUploadConfigResponse as any])) + buildPaginatedList(new PageInfo(), [mockUploadConfigResponse as any])), ); groupService.findById.and.returnValues( createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), - createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), ); bitstreamService.getUploadedFileList.and.returnValue(observableOf([])); + bitstreamService.getUploadedFilesData.and.returnValue(observableOf({ primary: null, files: [] })); }; TestBed.configureTestingModule({ imports: [ - BrowserModule, CommonModule, - TranslateModule.forRoot() - ], - declarations: [ + TranslateModule.forRoot(), SubmissionSectionUploadComponent, - TestComponent + TestComponent, ], providers: [ { provide: CollectionDataService, useValue: collectionDataService }, @@ -182,11 +192,19 @@ describe('SubmissionSectionUploadComponent test suite', () => { { provide: SectionUploadService, useValue: bitstreamService }, { provide: 'sectionDataProvider', useValue: sectionObject }, { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ChangeDetectorRef, - SubmissionSectionUploadComponent + SubmissionSectionUploadComponent, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents().then(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(SubmissionSectionUploadComponent, { + remove: { + imports: [AlertComponent], + }, + }) + .compileComponents().then(); })); describe('', () => { @@ -230,11 +248,11 @@ describe('SubmissionSectionUploadComponent test suite', () => { }); it('should init component properly', () => { - + bitstreamService.getUploadedFilesData.and.returnValue(observableOf({ primary: null, files: [] })); submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new Collection(), mockCollection, { - defaultAccessConditions: createSuccessfulRemoteDataObject$(mockDefaultAccessCondition) + defaultAccessConditions: createSuccessfulRemoteDataObject$(mockDefaultAccessCondition), }))); resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); @@ -243,18 +261,11 @@ describe('SubmissionSectionUploadComponent test suite', () => { groupService.findById.and.returnValues( createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), - createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), ); - bitstreamService.getUploadedFileList.and.returnValue(observableOf([])); - comp.onSectionInit(); - const expectedGroupsMap = new Map([ - [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], - [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], - ]); - expect(comp.collectionId).toBe(collectionId); expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); @@ -262,12 +273,12 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.fileList).toEqual([]); - expect(compAsAny.fileIndexes).toEqual([]); expect(compAsAny.fileNames).toEqual([]); - + expect(compAsAny.primaryBitstreamUUID).toEqual(null); }); it('should init file list properly', () => { + bitstreamService.getUploadedFilesData.and.returnValue(observableOf({ primary: null, files: [] })); submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); @@ -279,10 +290,10 @@ describe('SubmissionSectionUploadComponent test suite', () => { groupService.findById.and.returnValues( createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), - createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), ); - bitstreamService.getUploadedFileList.and.returnValue(observableOf(mockUploadFiles)); + bitstreamService.getUploadedFilesData.and.returnValue(observableOf(mockUploadFilesData)); comp.onSectionInit(); @@ -298,12 +309,14 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.fileList).toEqual(mockUploadFiles); - expect(compAsAny.fileIndexes).toEqual(['123456-test-upload']); + expect(compAsAny.primaryBitstreamUUID).toEqual(null); expect(compAsAny.fileNames).toEqual(['123456-test-upload.jpg']); }); it('should properly read the section status when required is true', () => { + bitstreamService.getUploadedFilesData.and.returnValue(observableOf({ primary: null, files: [] })); + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); @@ -314,12 +327,12 @@ describe('SubmissionSectionUploadComponent test suite', () => { groupService.findById.and.returnValues( createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), - createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), ); bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { a: [], - b: mockUploadFiles + b: mockUploadFiles, })); comp.onSectionInit(); @@ -328,13 +341,14 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { c: false, - d: true + d: true, })); }); it('should properly read the section status when required is false', () => { submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + bitstreamService.getUploadedFilesData.and.returnValue(observableOf({ primary: null, files: [] })); collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); @@ -343,12 +357,12 @@ describe('SubmissionSectionUploadComponent test suite', () => { groupService.findById.and.returnValues( createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), - createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), ); bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { a: [], - b: mockUploadFiles + b: mockUploadFiles, })); comp.onSectionInit(); @@ -357,7 +371,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { c: true, - d: true + d: true, })); }); }); @@ -366,7 +380,10 @@ describe('SubmissionSectionUploadComponent test suite', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ``, + standalone: true, + imports: [ + CommonModule], }) class TestComponent { diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index 10203adbc0d..58008c9dfb6 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -1,36 +1,60 @@ -import { ChangeDetectorRef, Component, Inject } from '@angular/core'; - +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Inject, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, combineLatest as observableCombineLatest, Observable, - Subscription + Subscription, } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, + mergeMap, + switchMap, + tap, +} from 'rxjs/operators'; +import { WorkspaceitemSectionUploadObject } from 'src/app/core/submission/models/workspaceitem-section-upload.model'; -import { SectionModelComponent } from '../models/section.model'; -import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shared/empty.util'; -import { SectionUploadService } from './section-upload.service'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { ResourcePolicyDataService } from '../../../core/resource-policy/resource-policy-data.service'; -import { SubmissionUploadsConfigDataService } from '../../../core/config/submission-uploads-config-data.service'; -import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { AccessConditionOption } from '../../../core/config/models/config-access-condition-option.model'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; -import { SectionsType } from '../sections-type'; -import { renderSectionFor } from '../sections-decorator'; -import { SectionDataObject } from '../models/section-data.model'; -import { SubmissionObjectEntry } from '../../objects/submission-objects.reducer'; -import { AlertType } from '../../../shared/alert/alert-type'; +import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; +import { SubmissionUploadsConfigDataService } from '../../../core/config/submission-uploads-config-data.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; import { RemoteData } from '../../../core/data/remote-data'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; import { Group } from '../../../core/eperson/models/group.model'; -import { SectionsService } from '../sections.service'; -import { SubmissionService } from '../../submission.service'; +import { ResourcePolicyDataService } from '../../../core/resource-policy/resource-policy-data.service'; import { Collection } from '../../../core/shared/collection.model'; -import { AccessConditionOption } from '../../../core/config/models/config-access-condition-option.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { AlertType } from '../../../shared/alert/alert-type'; +import { + hasValue, + isNotEmpty, + isNotUndefined, + isUndefined, +} from '../../../shared/empty.util'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { SubmissionObjectEntry } from '../../objects/submission-objects.reducer'; +import { SubmissionService } from '../../submission.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SubmissionSectionUploadAccessConditionsComponent } from './accessConditions/submission-section-upload-access-conditions.component'; +import { ThemedSubmissionSectionUploadFileComponent } from './file/themed-section-upload-file.component'; +import { SectionUploadService } from './section-upload.service'; export const POLICY_DEFAULT_NO_LIST = 1; // Banner1 export const POLICY_DEFAULT_WITH_LIST = 2; // Banner2 @@ -47,8 +71,17 @@ export interface AccessConditionGroupsMapEntry { selector: 'ds-submission-section-upload', styleUrls: ['./section-upload.component.scss'], templateUrl: './section-upload.component.html', + imports: [ + ThemedSubmissionSectionUploadFileComponent, + SubmissionSectionUploadAccessConditionsComponent, + NgIf, + AlertComponent, + TranslateModule, + NgForOf, + AsyncPipe, + ], + standalone: true, }) -@renderSectionFor(SectionsType.Upload) export class SubmissionSectionUploadComponent extends SectionModelComponent { /** @@ -58,10 +91,10 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { public AlertTypeEnum = AlertType; /** - * The array containing the keys of file list array + * The uuid of primary bitstream file * @type {Array} */ - public fileIndexes: string[] = []; + public primaryBitstreamUUID: string | null = null; /** * The file list @@ -158,8 +191,8 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { switchMap((config: SubmissionUploadsModel) => config.metadata.pipe( getFirstSucceededRemoteData(), - map((remoteData: RemoteData) => remoteData.payload) - ) + map((remoteData: RemoteData) => remoteData.payload), + ), )); this.subs.push( @@ -171,7 +204,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { filter((rd: RemoteData) => isNotUndefined((rd.payload))), tap((collectionRemoteData: RemoteData) => this.collectionName = this.dsoNameService.getName(collectionRemoteData.payload)), // TODO review this part when https://github.com/DSpace/dspace-angular/issues/575 is resolved -/* mergeMap((collectionRemoteData: RemoteData) => { + /* mergeMap((collectionRemoteData: RemoteData) => { return this.resourcePolicyService.findByHref( (collectionRemoteData.payload as any)._links.defaultAccessConditions.href ); @@ -194,29 +227,21 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { this.changeDetectorRef.detectChanges(); }), - // retrieve submission's bitstreams from state - observableCombineLatest(this.configMetadataForm$, - this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id)).pipe( - filter(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { - return isNotEmpty(configMetadataForm) && isNotUndefined(fileList); + // retrieve submission's bitstream data from state + combineLatest([ + this.configMetadataForm$, + this.bitstreamService.getUploadedFilesData(this.submissionId, this.sectionData.id), + ]).pipe( + filter(([configMetadataForm, sectionUploadObject]: [SubmissionFormsModel, WorkspaceitemSectionUploadObject]) => { + return isNotEmpty(configMetadataForm) && isNotEmpty(sectionUploadObject); }), - distinctUntilChanged()) - .subscribe(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { - this.fileList = []; - this.fileIndexes = []; - this.fileNames = []; - this.changeDetectorRef.detectChanges(); - if (isNotUndefined(fileList) && fileList.length > 0) { - fileList.forEach((file) => { - this.fileList.push(file); - this.fileIndexes.push(file.uuid); - this.fileNames.push(this.getFileName(configMetadataForm, file)); - }); - } - - this.changeDetectorRef.detectChanges(); - } - ) + distinctUntilChanged(), + ).subscribe(([configMetadataForm, { primary, files }]: [SubmissionFormsModel, WorkspaceitemSectionUploadObject]) => { + this.primaryBitstreamUUID = primary; + this.fileList = files; + this.fileNames = Array.from(files, file => this.getFileName(configMetadataForm, file)); + this.changeDetectorRef.detectChanges(); + }), ); } diff --git a/src/app/submission/sections/upload/section-upload.service.spec.ts b/src/app/submission/sections/upload/section-upload.service.spec.ts new file mode 100644 index 00000000000..c57aac0ac93 --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.service.spec.ts @@ -0,0 +1,70 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { JsonPatchOperationPathCombiner } from 'src/app/core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from 'src/app/core/json-patch/builder/json-patch-operations-builder'; + +import { SectionUploadService } from './section-upload.service'; + +const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), +}); + +describe('SectionUploadService test suite', () => { + let sectionUploadService: SectionUploadService; + let operationsBuilder: any; + const pathCombiner = new JsonPatchOperationPathCombiner('sections', 'upload'); + const primaryPath = pathCombiner.getPath('primary'); + const fileId = 'test'; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [StoreModule], + providers: [ + { provide: Store, useValue: {} }, + SectionUploadService, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + ], + schemas: [NO_ERRORS_SCHEMA], + }); + })); + + beforeEach(() => { + sectionUploadService = TestBed.inject(SectionUploadService); + operationsBuilder = TestBed.inject(JsonPatchOperationsBuilder); + }); + + [ + { + initialPrimary: null, + primary: true, + operationName: 'add', + expected: [primaryPath, fileId, false, true], + }, + { + initialPrimary: true, + primary: false, + operationName: 'remove', + expected: [primaryPath], + }, + { + initialPrimary: false, + primary: true, + operationName: 'replace', + expected: [primaryPath, fileId, true], + }, + ].forEach(({ initialPrimary, primary, operationName, expected }) => { + it(`updatePrimaryBitstreamOperation should add ${operationName} operation`, () => { + const path = pathCombiner.getPath('primary'); + sectionUploadService.updatePrimaryBitstreamOperation(path, initialPrimary, primary, fileId); + expect(operationsBuilder[operationName]).toHaveBeenCalledWith(...expected); + }); + }); +}); diff --git a/src/app/submission/sections/upload/section-upload.service.ts b/src/app/submission/sections/upload/section-upload.service.ts index a851fa9dafa..b4abe366c04 100644 --- a/src/app/submission/sections/upload/section-upload.service.ts +++ b/src/app/submission/sections/upload/section-upload.service.ts @@ -1,31 +1,87 @@ import { Injectable } from '@angular/core'; - -import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, +} from 'rxjs/operators'; +import { JsonPatchOperationPathObject } from 'src/app/core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from 'src/app/core/json-patch/builder/json-patch-operations-builder'; +import { WorkspaceitemSectionUploadObject } from 'src/app/core/submission/models/workspaceitem-section-upload.model'; -import { SubmissionState } from '../../submission.reducers'; +import { WorkspaceitemSectionUploadFileObject } from '../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { isUndefined } from '../../../shared/empty.util'; import { DeleteUploadedFileAction, EditFileDataAction, - NewUploadedFileAction + EditFilePrimaryBitstreamAction, + NewUploadedFileAction, } from '../../objects/submission-objects.actions'; -import { submissionUploadedFileFromUuidSelector, submissionUploadedFilesFromIdSelector } from '../../selectors'; -import { isUndefined } from '../../../shared/empty.util'; -import { WorkspaceitemSectionUploadFileObject } from '../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { + submissionSectionDataFromIdSelector, + submissionUploadedFileFromUuidSelector, + submissionUploadedFilesFromIdSelector, +} from '../../selectors'; +import { SubmissionState } from '../../submission.reducers'; /** * A service that provides methods to handle submission's bitstream state. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class SectionUploadService { /** * Initialize service variables * * @param {Store} store + * @param {JsonPatchOperationsBuilder} operationsBuilder */ - constructor(private store: Store) {} + constructor(private store: Store, private operationsBuilder: JsonPatchOperationsBuilder) {} + + /** + * Define and add an operation based on a change + * + * @param path + * The path to endpoint + * @param intitialPrimary + * The initial primary indicator + * @param primary + * the new primary indicator + * @param fileId + * The file id + * @returns {void} + */ + public updatePrimaryBitstreamOperation(path: JsonPatchOperationPathObject, intitialPrimary: boolean | null, primary: boolean | null, fileId: string): void { + if (intitialPrimary === null && primary) { + this.operationsBuilder.add(path, fileId, false, true); + return; + } + + if (intitialPrimary !== primary) { + if (primary) { + this.operationsBuilder.replace(path, fileId, true); + return; + } + this.operationsBuilder.remove(path); + } + } + + /** + * Return submission's bitstream data from state + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @returns {WorkspaceitemSectionUploadObject} + * Returns submission's bitstream data + */ + public getUploadedFilesData(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionSectionDataFromIdSelector(submissionId, sectionId)).pipe( + map((state) => state), + distinctUntilChanged()); + } /** * Return submission's bitstream list from state @@ -100,7 +156,23 @@ export class SectionUploadService { */ public addUploadedFile(submissionId: string, sectionId: string, fileUUID: string, data: WorkspaceitemSectionUploadFileObject) { this.store.dispatch( - new NewUploadedFileAction(submissionId, sectionId, fileUUID, data) + new NewUploadedFileAction(submissionId, sectionId, fileUUID, data), + ); + } + + /** + * Update primary bitstream into the state + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param fileUUID + * The bitstream UUID + */ + public updateFilePrimaryBitstream(submissionId: string, sectionId: string, fileUUID: string | null) { + this.store.dispatch( + new EditFilePrimaryBitstreamAction(submissionId, sectionId, fileUUID), ); } @@ -118,7 +190,7 @@ export class SectionUploadService { */ public updateFileData(submissionId: string, sectionId: string, fileUUID: string, data: WorkspaceitemSectionUploadFileObject) { this.store.dispatch( - new EditFileDataAction(submissionId, sectionId, fileUUID, data) + new EditFileDataAction(submissionId, sectionId, fileUUID, data), ); } @@ -134,7 +206,7 @@ export class SectionUploadService { */ public removeUploadedFile(submissionId: string, sectionId: string, fileUUID: string) { this.store.dispatch( - new DeleteUploadedFileAction(submissionId, sectionId, fileUUID) + new DeleteUploadedFileAction(submissionId, sectionId, fileUUID), ); } } diff --git a/src/app/submission/selectors.ts b/src/app/submission/selectors.ts index 6284aca8925..432a5377a5a 100644 --- a/src/app/submission/selectors.ts +++ b/src/app/submission/selectors.ts @@ -1,9 +1,16 @@ -import { createSelector, MemoizedSelector, Selector } from '@ngrx/store'; +import { + createSelector, + MemoizedSelector, + Selector, +} from '@ngrx/store'; import { hasValue } from '../shared/empty.util'; -import { submissionSelector, SubmissionState } from './submission.reducers'; -import { SubmissionObjectEntry} from './objects/submission-objects.reducer'; +import { SubmissionObjectEntry } from './objects/submission-objects.reducer'; import { SubmissionSectionObject } from './objects/submission-section-object.model'; +import { + submissionSelector, + SubmissionState, +} from './submission.reducers'; /** * Export a function to return a subset of the state by key diff --git a/src/app/submission/server-submission.service.ts b/src/app/submission/server-submission.service.ts index 3aa55a9d58e..993e38cdaad 100644 --- a/src/app/submission/server-submission.service.ts +++ b/src/app/submission/server-submission.service.ts @@ -1,10 +1,12 @@ import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; -import { Observable, of as observableOf } from 'rxjs'; - -import { SubmissionService } from './submission.service'; -import { SubmissionObject } from '../core/submission/models/submission-object.model'; import { RemoteData } from '../core/data/remote-data'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { SubmissionService } from './submission.service'; /** * Instance of SubmissionService used on SSR. diff --git a/src/app/submission/submission.effects.ts b/src/app/submission/submission.effects.ts index 30e01451d13..5a4105e4ab3 100644 --- a/src/app/submission/submission.effects.ts +++ b/src/app/submission/submission.effects.ts @@ -1,5 +1,5 @@ import { SubmissionObjectEffects } from './objects/submission-objects.effects'; export const submissionEffects = [ - SubmissionObjectEffects + SubmissionObjectEffects, ]; diff --git a/src/app/submission/submission.module.ts b/src/app/submission/submission.module.ts deleted file mode 100644 index cf0ab2b369a..00000000000 --- a/src/app/submission/submission.module.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CoreModule } from '../core/core.module'; -import { SharedModule } from '../shared/shared.module'; - -import { SubmissionSectionFormComponent } from './sections/form/section-form.component'; -import { SectionsDirective } from './sections/sections.directive'; -import { SectionsService } from './sections/sections.service'; -import { SubmissionFormCollectionComponent } from './form/collection/submission-form-collection.component'; -import { SubmissionFormFooterComponent } from './form/footer/submission-form-footer.component'; -import { SubmissionFormComponent } from './form/submission-form.component'; -import { SubmissionFormSectionAddComponent } from './form/section-add/submission-form-section-add.component'; -import { SubmissionSectionContainerComponent } from './sections/container/section-container.component'; -import { CommonModule } from '@angular/common'; -import { Action, StoreConfig, StoreModule } from '@ngrx/store'; -import { EffectsModule } from '@ngrx/effects'; -import { submissionReducers, SubmissionState } from './submission.reducers'; -import { submissionEffects } from './submission.effects'; -import { SubmissionSectionUploadComponent } from './sections/upload/section-upload.component'; -import { SectionUploadService } from './sections/upload/section-upload.service'; -import { SubmissionUploadFilesComponent } from './form/submission-upload-files/submission-upload-files.component'; -import { SubmissionSectionLicenseComponent } from './sections/license/section-license.component'; -import { SubmissionUploadsConfigDataService } from '../core/config/submission-uploads-config-data.service'; -import { SubmissionEditComponent } from './edit/submission-edit.component'; -import { SubmissionSectionUploadFileComponent } from './sections/upload/file/section-upload-file.component'; -import { - SubmissionSectionUploadFileEditComponent -} from './sections/upload/file/edit/section-upload-file-edit.component'; -import { - SubmissionSectionUploadFileViewComponent -} from './sections/upload/file/view/section-upload-file-view.component'; -import { - SubmissionSectionUploadAccessConditionsComponent -} from './sections/upload/accessConditions/submission-section-upload-access-conditions.component'; -import { SubmissionSubmitComponent } from './submit/submission-submit.component'; -import { storeModuleConfig } from '../app.reducer'; -import { SubmissionImportExternalComponent } from './import-external/submission-import-external.component'; -import { - SubmissionImportExternalSearchbarComponent -} from './import-external/import-external-searchbar/submission-import-external-searchbar.component'; -import { - SubmissionImportExternalPreviewComponent -} from './import-external/import-external-preview/submission-import-external-preview.component'; -import { - SubmissionImportExternalCollectionComponent -} from './import-external/import-external-collection/submission-import-external-collection.component'; -import { SubmissionSectionCcLicensesComponent } from './sections/cc-license/submission-section-cc-licenses.component'; -import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; -import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; -import { ThemedSubmissionEditComponent } from './edit/themed-submission-edit.component'; -import { ThemedSubmissionSubmitComponent } from './submit/themed-submission-submit.component'; -import { ThemedSubmissionImportExternalComponent } from './import-external/themed-submission-import-external.component'; -import { ThemedSubmissionSectionUploadFileComponent } from './sections/upload/file/themed-section-upload-file.component'; -import { FormModule } from '../shared/form/form.module'; -import { NgbAccordionModule, NgbCollapseModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; -import { SubmissionSectionAccessesComponent } from './sections/accesses/section-accesses.component'; -import { SubmissionAccessesConfigDataService } from '../core/config/submission-accesses-config-data.service'; -import { SectionAccessesService } from './sections/accesses/section-accesses.service'; -import { SubmissionSectionSherpaPoliciesComponent } from './sections/sherpa-policies/section-sherpa-policies.component'; -import { ContentAccordionComponent } from './sections/sherpa-policies/content-accordion/content-accordion.component'; -import { PublisherPolicyComponent } from './sections/sherpa-policies/publisher-policy/publisher-policy.component'; -import { - PublicationInformationComponent -} from './sections/sherpa-policies/publication-information/publication-information.component'; -import { UploadModule } from '../shared/upload/upload.module'; -import { - MetadataInformationComponent -} from './sections/sherpa-policies/metadata-information/metadata-information.component'; -import { SectionFormOperationsService } from './sections/form/section-form-operations.service'; -import {SubmissionSectionIdentifiersComponent} from './sections/identifiers/section-identifiers.component'; - -const ENTRY_COMPONENTS = [ - // put only entry components that use custom decorator - SubmissionSectionUploadComponent, - SubmissionSectionFormComponent, - SubmissionSectionLicenseComponent, - SubmissionSectionCcLicensesComponent, - SubmissionSectionAccessesComponent, - SubmissionSectionSherpaPoliciesComponent, -]; - -const DECLARATIONS = [ - ...ENTRY_COMPONENTS, - SectionsDirective, - SubmissionEditComponent, - ThemedSubmissionEditComponent, - SubmissionFormSectionAddComponent, - SubmissionFormCollectionComponent, - SubmissionFormComponent, - SubmissionFormFooterComponent, - SubmissionSubmitComponent, - ThemedSubmissionSubmitComponent, - SubmissionUploadFilesComponent, - SubmissionSectionContainerComponent, - SubmissionSectionUploadAccessConditionsComponent, - SubmissionSectionUploadFileComponent, - SubmissionSectionUploadFileEditComponent, - SubmissionSectionUploadFileViewComponent, - SubmissionSectionIdentifiersComponent, - SubmissionImportExternalComponent, - ThemedSubmissionImportExternalComponent, - SubmissionImportExternalSearchbarComponent, - SubmissionImportExternalPreviewComponent, - SubmissionImportExternalCollectionComponent, - ContentAccordionComponent, - PublisherPolicyComponent, - PublicationInformationComponent, - MetadataInformationComponent, - ThemedSubmissionSectionUploadFileComponent, -]; - -@NgModule({ - imports: [ - CommonModule, - CoreModule.forRoot(), - SharedModule, - StoreModule.forFeature('submission', submissionReducers, storeModuleConfig as StoreConfig), - EffectsModule.forFeature(submissionEffects), - JournalEntitiesModule.withEntryComponents(), - ResearchEntitiesModule.withEntryComponents(), - FormModule, - NgbModalModule, - NgbCollapseModule, - NgbAccordionModule, - UploadModule, - ], - declarations: DECLARATIONS, - exports: [ - ...DECLARATIONS, - FormModule, - ], - providers: [ - SectionUploadService, - SectionsService, - SubmissionUploadsConfigDataService, - SubmissionAccessesConfigDataService, - SectionAccessesService, - SectionFormOperationsService, - ] -}) - -/** - * This module handles all components that are necessary for the submission process - */ -export class SubmissionModule { - /** - * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during SSR otherwise - */ - static withEntryComponents() { - return { - ngModule: SubmissionModule, - providers: ENTRY_COMPONENTS.map((component) => ({ provide: component })) - }; - } -} diff --git a/src/app/submission/submission.reducers.ts b/src/app/submission/submission.reducers.ts index 8d98a7d5c19..23cacbf13fe 100644 --- a/src/app/submission/submission.reducers.ts +++ b/src/app/submission/submission.reducers.ts @@ -1,8 +1,11 @@ -import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; +import { + ActionReducerMap, + createFeatureSelector, +} from '@ngrx/store'; import { submissionObjectReducer, - SubmissionObjectState + SubmissionObjectState, } from './objects/submission-objects.reducer'; /** diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index 1e2be5b6121..b8f982101ce 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -1,25 +1,58 @@ -import { StoreModule } from '@ngrx/store'; -import { fakeAsync, flush, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; - -import { of as observableOf, throwError as observableThrowError } from 'rxjs'; +import { + fakeAsync, + flush, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { StoreModule } from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { + of as observableOf, + throwError as observableThrowError, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { cold, getTestScheduler, hot, } from 'jasmine-marbles'; -import { RouterMock } from '../shared/mocks/router.mock'; -import { SubmissionService } from './submission.service'; -import { submissionReducers } from './submission.reducers'; -import { SubmissionRestService } from '../core/submission/submission-rest.service'; -import { RouteService } from '../core/services/route.service'; -import { SubmissionRestServiceStub } from '../shared/testing/submission-rest-service.stub'; -import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; +import { environment } from '../../environments/environment'; +import { storeModuleConfig } from '../app.reducer'; +import { ErrorResponse } from '../core/cache/response.models'; +import { RequestService } from '../core/data/request.service'; +import { RequestError } from '../core/data/request-error.model'; import { HttpOptions } from '../core/dspace-rest/dspace-rest.service'; +import { RouteService } from '../core/services/route.service'; +import { Item } from '../core/shared/item.model'; +import { SearchService } from '../core/shared/search/search.service'; +import { SubmissionJsonPatchOperationsService } from '../core/submission/submission-json-patch-operations.service'; +import { SubmissionRestService } from '../core/submission/submission-rest.service'; import { SubmissionScopeType } from '../core/submission/submission-scope-type'; -import { mockSubmissionDefinition, mockSubmissionRestResponse } from '../shared/mocks/submission.mock'; -import { NotificationsService } from '../shared/notifications/notifications.service'; +import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; +import { getMockRequestService } from '../shared/mocks/request.service.mock'; +import { RouterMock } from '../shared/mocks/router.mock'; +import { getMockSearchService } from '../shared/mocks/search-service.mock'; +import { + mockSubmissionDefinition, + mockSubmissionRestResponse, +} from '../shared/mocks/submission.mock'; import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { createFailedRemoteDataObject } from '../shared/remote-data.utils'; +import { SubmissionJsonPatchOperationsServiceStub } from '../shared/testing/submission-json-patch-operations-service.stub'; +import { SubmissionRestServiceStub } from '../shared/testing/submission-rest-service.stub'; +import { SectionScope } from './objects/section-visibility.model'; import { CancelSubmissionFormAction, ChangeSubmissionCollectionAction, @@ -30,18 +63,10 @@ import { SaveForLaterSubmissionFormAction, SaveSubmissionFormAction, SaveSubmissionSectionFormAction, - SetActiveSectionAction + SetActiveSectionAction, } from './objects/submission-objects.actions'; -import { createFailedRemoteDataObject, } from '../shared/remote-data.utils'; -import { getMockSearchService } from '../shared/mocks/search-service.mock'; -import { getMockRequestService } from '../shared/mocks/request.service.mock'; -import { RequestService } from '../core/data/request.service'; -import { SearchService } from '../core/shared/search/search.service'; -import { Item } from '../core/shared/item.model'; -import { storeModuleConfig } from '../app.reducer'; -import { environment } from '../../environments/environment'; -import { SubmissionJsonPatchOperationsService } from '../core/submission/submission-json-patch-operations.service'; -import { SubmissionJsonPatchOperationsServiceStub } from '../shared/testing/submission-json-patch-operations-service.stub'; +import { submissionReducers } from './submission.reducers'; +import { SubmissionService } from './submission.service'; describe('SubmissionService test suite', () => { const collectionId = '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f'; @@ -58,10 +83,11 @@ describe('SubmissionService test suite', () => { extraction: { config: '', mandatory: true, + scope: SectionScope.Submission, sectionType: 'utils', visibility: { main: 'HIDDEN', - other: 'HIDDEN' + other: 'HIDDEN', }, collapsed: false, enabled: true, @@ -69,15 +95,16 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, collection: { config: '', mandatory: true, + scope: SectionScope.Submission, sectionType: 'collection', visibility: { main: 'HIDDEN', - other: 'HIDDEN' + other: 'HIDDEN', }, collapsed: false, enabled: true, @@ -85,7 +112,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, keyinformation: { header: 'submit.progressbar.describe.keyinformation', @@ -98,7 +125,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, indexing: { header: 'submit.progressbar.describe.indexing', @@ -111,7 +138,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, publicationchannel: { header: 'submit.progressbar.describe.publicationchannel', @@ -124,7 +151,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, }, acknowledgement: { header: 'submit.progressbar.describe.acknowledgement', @@ -137,7 +164,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, identifiers: { header: 'submit.progressbar.describe.identifiers', @@ -150,7 +177,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, references: { header: 'submit.progressbar.describe.references', @@ -163,7 +190,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, upload: { header: 'submit.progressbar.upload', @@ -176,7 +203,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, license: { header: 'submit.progressbar.license', @@ -185,7 +212,7 @@ describe('SubmissionService test suite', () => { sectionType: 'license', visibility: { main: null, - other: 'READONLY' + other: 'READONLY', }, collapsed: false, enabled: true, @@ -193,14 +220,14 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false - } + isValid: false, + }, }, isLoading: false, savePending: false, - depositPending: false - } - } + depositPending: false, + }, + }, }; const validSubState = { objects: { @@ -213,10 +240,11 @@ describe('SubmissionService test suite', () => { extraction: { config: '', mandatory: true, + scope: SectionScope.Submission, sectionType: 'utils', visibility: { main: 'HIDDEN', - other: 'HIDDEN' + other: 'HIDDEN', }, collapsed: false, enabled: true, @@ -224,15 +252,16 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, collection: { config: '', mandatory: true, + scope: SectionScope.Submission, sectionType: 'collection', visibility: { main: 'HIDDEN', - other: 'HIDDEN' + other: 'HIDDEN', }, collapsed: false, enabled: true, @@ -240,7 +269,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, keyinformation: { header: 'submit.progressbar.describe.keyinformation', @@ -253,7 +282,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, }, indexing: { header: 'submit.progressbar.describe.indexing', @@ -266,7 +295,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, publicationchannel: { header: 'submit.progressbar.describe.publicationchannel', @@ -279,7 +308,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, }, acknowledgement: { header: 'submit.progressbar.describe.acknowledgement', @@ -292,7 +321,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, identifiers: { header: 'submit.progressbar.describe.identifiers', @@ -305,7 +334,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, references: { header: 'submit.progressbar.describe.references', @@ -318,7 +347,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: false + isValid: false, }, upload: { header: 'submit.progressbar.upload', @@ -331,7 +360,7 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true + isValid: true, }, license: { header: 'submit.progressbar.license', @@ -340,7 +369,7 @@ describe('SubmissionService test suite', () => { sectionType: 'license', visibility: { main: null, - other: 'READONLY' + other: 'READONLY', }, collapsed: false, enabled: true, @@ -348,14 +377,14 @@ describe('SubmissionService test suite', () => { errorsToShow: [], serverValidationErrors: [], isLoading: false, - isValid: true - } + isValid: true, + }, }, isLoading: false, savePending: false, - depositPending: false - } - } + depositPending: false, + }, + }, }; const restService = new SubmissionRestServiceStub(); const router = new RouterMock(); @@ -378,9 +407,9 @@ describe('SubmissionService test suite', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }) + useClass: TranslateLoaderMock, + }, + }), ], providers: [ { provide: Router, useValue: router }, @@ -392,8 +421,8 @@ describe('SubmissionService test suite', () => { NotificationsService, RouteService, SubmissionService, - TranslateService - ] + TranslateService, + ], }).compileComponents(); })); @@ -470,7 +499,7 @@ describe('SubmissionService test suite', () => { submissionDefinition, {}, new Item(), - null + null, ); const expected = new InitSubmissionFormAction( collectionId, @@ -487,7 +516,7 @@ describe('SubmissionService test suite', () => { describe('dispatchDeposit', () => { it('should dispatch a new SaveAndDepositSubmissionAction', () => { - service.dispatchDeposit(submissionId,); + service.dispatchDeposit(submissionId); const expected = new SaveAndDepositSubmissionAction(submissionId); expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); @@ -496,7 +525,7 @@ describe('SubmissionService test suite', () => { describe('dispatchDiscard', () => { it('should dispatch a new DiscardSubmissionAction', () => { - service.dispatchDiscard(submissionId,); + service.dispatchDiscard(submissionId); const expected = new DiscardSubmissionAction(submissionId); expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); @@ -521,7 +550,7 @@ describe('SubmissionService test suite', () => { describe('dispatchSaveForLater', () => { it('should dispatch a new SaveForLaterSubmissionFormAction', () => { - service.dispatchSaveForLater(submissionId,); + service.dispatchSaveForLater(submissionId); const expected = new SaveForLaterSubmissionFormAction(submissionId); expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); @@ -540,7 +569,7 @@ describe('SubmissionService test suite', () => { describe('getSubmissionObject', () => { it('should return submission object state from the store', () => { spyOn((service as any).store, 'select').and.returnValue(hot('a', { - a: subState.objects[826] + a: subState.objects[826], })); const result = service.getSubmissionObject('826'); @@ -553,7 +582,7 @@ describe('SubmissionService test suite', () => { describe('getActiveSectionId', () => { it('should return current active submission form section', () => { spyOn((service as any).store, 'select').and.returnValue(hot('a', { - a: subState.objects[826] + a: subState.objects[826], })); const result = service.getActiveSectionId('826'); @@ -566,8 +595,9 @@ describe('SubmissionService test suite', () => { describe('getSubmissionSections', () => { it('should return submission form sections', () => { + spyOn(service, 'getSubmissionScope').and.returnValue(SubmissionScopeType.WorkspaceItem); spyOn((service as any).store, 'select').and.returnValue(hot('a|', { - a: subState.objects[826] + a: subState.objects[826], })); const result = service.getSubmissionSections('826'); @@ -583,7 +613,7 @@ describe('SubmissionService test suite', () => { sectionType: 'submission-form', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.describe.indexing', @@ -593,7 +623,7 @@ describe('SubmissionService test suite', () => { sectionType: 'submission-form', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.describe.publicationchannel', @@ -603,7 +633,7 @@ describe('SubmissionService test suite', () => { sectionType: 'submission-form', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.describe.acknowledgement', @@ -613,7 +643,7 @@ describe('SubmissionService test suite', () => { sectionType: 'submission-form', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.describe.identifiers', @@ -623,7 +653,7 @@ describe('SubmissionService test suite', () => { sectionType: 'submission-form', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.describe.references', @@ -633,7 +663,7 @@ describe('SubmissionService test suite', () => { sectionType: 'submission-form', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.upload', @@ -643,7 +673,7 @@ describe('SubmissionService test suite', () => { sectionType: 'upload', data: {}, errorsToShow: [], - serverValidationErrors: [] + serverValidationErrors: [], }, { header: 'submit.progressbar.license', @@ -653,9 +683,9 @@ describe('SubmissionService test suite', () => { sectionType: 'license', data: {}, errorsToShow: [], - serverValidationErrors: [] - } - ] + serverValidationErrors: [], + }, + ], }); expect(result).toBeObservable(expected); @@ -665,7 +695,7 @@ describe('SubmissionService test suite', () => { describe('getDisabledSectionsList', () => { it('should return list of submission disabled sections', () => { spyOn((service as any).store, 'select').and.returnValue(hot('-a|', { - a: subState.objects[826] + a: subState.objects[826], })); const result = service.getDisabledSectionsList('826'); @@ -688,8 +718,8 @@ describe('SubmissionService test suite', () => { { header: 'submit.progressbar.describe.references', id: 'references', - } - ] + }, + ], }); expect(result).toBeObservable(expected); @@ -735,14 +765,15 @@ describe('SubmissionService test suite', () => { describe('getSubmissionStatus', () => { it('should return properly submission status', () => { + spyOn(service, 'getSubmissionScope').and.returnValue(SubmissionScopeType.WorkspaceItem); spyOn((service as any).store, 'select').and.returnValue(hot('-a-b', { a: subState, - b: validSubState + b: validSubState, })); const result = service.getSubmissionStatus('826'); const expected = cold('cc-d', { c: false, - d: true + d: true, }); expect(result).toBeObservable(expected); @@ -752,12 +783,12 @@ describe('SubmissionService test suite', () => { describe('getSubmissionSaveProcessingStatus', () => { it('should return submission save processing status', () => { spyOn((service as any).store, 'select').and.returnValue(hot('-a', { - a: subState.objects[826] + a: subState.objects[826], })); const result = service.getSubmissionSaveProcessingStatus('826'); const expected = cold('bb', { - b: false + b: false, }); expect(result).toBeObservable(expected); @@ -767,12 +798,12 @@ describe('SubmissionService test suite', () => { describe('getSubmissionDepositProcessingStatus', () => { it('should return submission deposit processing status', () => { spyOn((service as any).store, 'select').and.returnValue(hot('-a', { - a: subState.objects[826] + a: subState.objects[826], })); const result = service.getSubmissionDepositProcessingStatus('826'); const expected = cold('bb', { - b: false + b: false, }); expect(result).toBeObservable(expected); @@ -794,41 +825,207 @@ describe('SubmissionService test suite', () => { }); describe('isSectionHidden', () => { - it('should return true/false when section is hidden/visible', () => { - let section: any = { - config: '', - header: '', - mandatory: true, - sectionType: 'collection' as any, - visibility: { - main: 'HIDDEN', - other: 'HIDDEN' - }, - collapsed: false, - enabled: true, - data: {}, - errorsToShow: [], - serverValidationErrors: [], - isLoading: false, - isValid: false - }; - expect(service.isSectionHidden(section)).toBeTruthy(); - - section = { - header: 'submit.progressbar.describe.keyinformation', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/keyinformation', - mandatory: true, - sectionType: 'submission-form', - collapsed: false, - enabled: true, - data: {}, - errorsToShow: [], - serverValidationErrors: [], - isLoading: false, - isValid: false - }; - expect(service.isSectionHidden(section)).toBeFalsy(); + describe('when submission scope is workspace', () => { + beforeEach(() => { + spyOn(service, 'getSubmissionScope').and.returnValue(SubmissionScopeType.WorkspaceItem); + }); + + describe('and section scope is workspace', () => { + it('should return true when visibility main is HIDDEN and visibility other is null', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: { + main: 'HIDDEN', + other: null, + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return true when both visibility main and other are HIDDEN', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: { + main: 'HIDDEN', + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return false when visibility main is null and visibility other is HIDDEN', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: { + main: null, + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + it('should return false when visibility is null', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: null, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + }); + + describe('and section scope is workflow', () => { + it('should return false when visibility main is HIDDEN and visibility other is null', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: { + main: 'HIDDEN', + other: null, + }, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + it('should return true when both visibility main and other are HIDDEN', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: { + main: 'HIDDEN', + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return true when visibility main is null and visibility other is HIDDEN', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: { + main: null, + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return false when visibility is null', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: null, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + }); + + describe('and section scope is null', () => { + it('should return false', () => { + let section: any = { + scope: null, + visibility: { + main: 'HIDDEN', + other: null, + }, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + }); + + }); + + describe('when submission scope is workflow', () => { + beforeEach(() => { + spyOn(service, 'getSubmissionScope').and.returnValue(SubmissionScopeType.WorkflowItem); + }); + + describe('and section scope is workspace', () => { + it('should return false when visibility main is HIDDEN and visibility other is null', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: { + main: 'HIDDEN', + other: null, + }, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + it('should return true when both visibility main and other are HIDDEN', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: { + main: 'HIDDEN', + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return true when visibility main is null and visibility other is HIDDEN', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: { + main: null, + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return false when visibility is null', () => { + let section: any = { + scope: SectionScope.Submission, + visibility: null, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + }); + + describe('and section scope is workflow', () => { + it('should return true when visibility main is HIDDEN and visibility other is null', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: { + main: 'HIDDEN', + other: null, + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return true when both visibility main and other are HIDDEN', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: { + main: 'HIDDEN', + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeTrue(); + }); + it('should return false when visibility main is null and visibility other is HIDDEN', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: { + main: null, + other: 'HIDDEN', + }, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + it('should return false when visibility is null', () => { + let section: any = { + scope: SectionScope.Workflow, + visibility: null, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + }); + + describe('and section scope is null', () => { + it('should return false', () => { + let section: any = { + scope: null, + visibility: { + main: 'HIDDEN', + other: null, + }, + }; + expect(service.isSectionHidden(section)).toBeFalse(); + }); + }); + }); + + }); describe('isSubmissionLoading', () => { @@ -836,7 +1033,7 @@ describe('SubmissionService test suite', () => { const spy = spyOn(service, 'getSubmissionObject').and.returnValue(observableOf({ isLoading: true })); let expected = cold('(b|)', { - b: true + b: true, }); expect(service.isSubmissionLoading(submissionId)).toBeObservable(expected); @@ -844,7 +1041,7 @@ describe('SubmissionService test suite', () => { spy.and.returnValue(observableOf({ isLoading: false })); expected = cold('(b|)', { - b: false + b: false, }); expect(service.isSubmissionLoading(submissionId)).toBeObservable(expected); @@ -906,7 +1103,7 @@ describe('SubmissionService test suite', () => { selfUrl, submissionDefinition, {}, - new Item() + new Item(), ) ; const expected = new ResetSubmissionFormAction( @@ -915,7 +1112,7 @@ describe('SubmissionService test suite', () => { selfUrl, {}, submissionDefinition, - new Item() + new Item(), ); expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); @@ -925,23 +1122,24 @@ describe('SubmissionService test suite', () => { describe('retrieveSubmission', () => { it('should retrieve submission from REST endpoint', () => { (service as any).restService.getDataById.and.returnValue(hot('a|', { - a: mockSubmissionRestResponse + a: mockSubmissionRestResponse, })); const result = service.retrieveSubmission('826'); const expected = cold('(b|)', { - b: jasmine.objectContaining({ payload: mockSubmissionRestResponse[0] }) + b: jasmine.objectContaining({ payload: mockSubmissionRestResponse[0] }), }); expect(result).toBeObservable(expected); }); it('should catch error from REST endpoint', () => { + const requestError = new RequestError('Internal Server Error'); + requestError.statusCode = 500; + const errorResponse = new ErrorResponse(requestError); + (service as any).restService.getDataById.and.callFake( - () => observableThrowError({ - statusCode: 500, - errorMessage: 'Internal Server Error', - }) + () => observableThrowError(errorResponse), ); service.retrieveSubmission('826').subscribe((r) => { diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index 9eb8cf110a5..ba8eff4f382 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -1,14 +1,57 @@ -import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; - -import { Observable, of as observableOf, Subscription, timer as observableTimer } from 'rxjs'; -import { catchError, concatMap, distinctUntilChanged, filter, find, map, startWith, take, tap } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, + Subscription, + timer as observableTimer, +} from 'rxjs'; +import { + catchError, + concatMap, + distinctUntilChanged, + filter, + find, + map, + startWith, + take, + tap, +} from 'rxjs/operators'; -import { submissionSelector, SubmissionState } from './submission.reducers'; -import { hasValue, isEmpty, isNotUndefined } from '../shared/empty.util'; +import { environment } from '../../environments/environment'; +import { ErrorResponse } from '../core/cache/response.models'; +import { SubmissionDefinitionsModel } from '../core/config/models/config-submission-definitions.model'; +import { RemoteData } from '../core/data/remote-data'; +import { RequestService } from '../core/data/request.service'; +import { HttpOptions } from '../core/dspace-rest/dspace-rest.service'; +import { RouteService } from '../core/services/route.service'; +import { Item } from '../core/shared/item.model'; +import { SearchService } from '../core/shared/search/search.service'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { WorkspaceitemSectionsObject } from '../core/submission/models/workspaceitem-sections.model'; +import { SubmissionJsonPatchOperationsService } from '../core/submission/submission-json-patch-operations.service'; +import { SubmissionRestService } from '../core/submission/submission-rest.service'; +import { SubmissionScopeType } from '../core/submission/submission-scope-type'; +import { + hasValue, + isEmpty, + isNotUndefined, +} from '../shared/empty.util'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, +} from '../shared/remote-data.utils'; +import { SectionScope } from './objects/section-visibility.model'; +import { SubmissionError } from './objects/submission-error.model'; import { CancelSubmissionFormAction, ChangeSubmissionCollectionAction, @@ -19,33 +62,34 @@ import { SaveForLaterSubmissionFormAction, SaveSubmissionFormAction, SaveSubmissionSectionFormAction, - SetActiveSectionAction + SetActiveSectionAction, } from './objects/submission-objects.actions'; import { SubmissionObjectEntry, - SubmissionSectionEntry + SubmissionSectionEntry, } from './objects/submission-objects.reducer'; -import { submissionObjectFromIdSelector } from './selectors'; -import { HttpOptions } from '../core/dspace-rest/dspace-rest.service'; -import { SubmissionRestService } from '../core/submission/submission-rest.service'; +import { SubmissionSectionObject } from './objects/submission-section-object.model'; import { SectionDataObject } from './sections/models/section-data.model'; -import { SubmissionScopeType } from '../core/submission/submission-scope-type'; -import { SubmissionObject } from '../core/submission/models/submission-object.model'; -import { RouteService } from '../core/services/route.service'; import { SectionsType } from './sections/sections-type'; -import { NotificationsService } from '../shared/notifications/notifications.service'; -import { SubmissionDefinitionsModel } from '../core/config/models/config-submission-definitions.model'; -import { WorkspaceitemSectionsObject } from '../core/submission/models/workspaceitem-sections.model'; -import { RemoteData } from '../core/data/remote-data'; -import { ErrorResponse } from '../core/cache/response.models'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; -import { RequestService } from '../core/data/request.service'; -import { SearchService } from '../core/shared/search/search.service'; -import { Item } from '../core/shared/item.model'; -import { environment } from '../../environments/environment'; -import { SubmissionJsonPatchOperationsService } from '../core/submission/submission-json-patch-operations.service'; -import { SubmissionSectionObject } from './objects/submission-section-object.model'; -import { SubmissionError } from './objects/submission-error.model'; +import { submissionObjectFromIdSelector } from './selectors'; +import { + submissionSelector, + SubmissionState, +} from './submission.reducers'; + +function getSubmissionSelector(submissionId: string): MemoizedSelector { + return createSelector( + submissionSelector, + (state: SubmissionState) => state.objects[submissionId], + ); +} + +function getSubmissionCollectionIdSelector(submissionId: string): MemoizedSelector { + return createSelector( + getSubmissionSelector(submissionId), + (submission: SubmissionObjectEntry) => submission?.collection, + ); +} /** * A service that provides methods used in submission process. @@ -96,10 +140,19 @@ export class SubmissionService { * @param collectionId * The collection id */ - changeSubmissionCollection(submissionId, collectionId) { + changeSubmissionCollection(submissionId: string, collectionId: string): void { this.store.dispatch(new ChangeSubmissionCollectionAction(submissionId, collectionId)); } + /** + * Listen to collection changes for a certain {@link SubmissionObject} + * + * @param submissionId The submission id + */ + getSubmissionCollectionId(submissionId: string): Observable { + return this.store.pipe(select(getSubmissionCollectionIdSelector(submissionId))); + } + /** * Perform a REST call to create a new workspaceitem and return response * @@ -217,7 +270,7 @@ export class SubmissionService { */ dispatchSave(submissionId, manual?: boolean) { this.getSubmissionSaveProcessingStatus(submissionId).pipe( - find((isPending: boolean) => !isPending) + find((isPending: boolean) => !isPending), ).subscribe(() => { this.store.dispatch(new SaveSubmissionFormAction(submissionId, manual)); }); @@ -452,9 +505,15 @@ export class SubmissionService { * true if section is hidden, false otherwise */ isSectionHidden(sectionData: SubmissionSectionObject): boolean { - return (isNotUndefined(sectionData.visibility) - && sectionData.visibility.main === 'HIDDEN' - && sectionData.visibility.other === 'HIDDEN'); + const submissionScope: SubmissionScopeType = this.getSubmissionScope(); + if (isEmpty(submissionScope) || isEmpty(sectionData.visibility) || isEmpty(sectionData.scope)) { + return false; + } + const convertedSubmissionScope: SectionScope = submissionScope.valueOf() === SubmissionScopeType.WorkspaceItem.valueOf() ? + SectionScope.Submission : SectionScope.Workflow; + const visibility = convertedSubmissionScope.valueOf() === sectionData.scope.valueOf() ? + sectionData.visibility.main : sectionData.visibility.other; + return visibility === 'HIDDEN'; } /** @@ -505,7 +564,7 @@ export class SubmissionService { } else { this.router.navigateByUrl(previousUrl); } - }))) + }))), ).subscribe(); } @@ -536,7 +595,7 @@ export class SubmissionService { selfUrl: string, submissionDefinition: SubmissionDefinitionsModel, sections: WorkspaceitemSectionsObject, - item: Item + item: Item, ) { this.store.dispatch(new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, sections, submissionDefinition, item)); } @@ -552,9 +611,11 @@ export class SubmissionService { find((submissionObjects: SubmissionObject[]) => isNotUndefined(submissionObjects)), map((submissionObjects: SubmissionObject[]) => createSuccessfulRemoteDataObject( submissionObjects[0])), - catchError((errorResponse: ErrorResponse) => { - return createFailedRemoteDataObject$(errorResponse.errorMessage, errorResponse.statusCode); - }) + catchError((errorResponse: unknown) => { + if (errorResponse instanceof ErrorResponse) { + return createFailedRemoteDataObject$(errorResponse.errorMessage, errorResponse.statusCode); + } + }), ); } diff --git a/src/app/submission/submit/submission-submit.component.spec.ts b/src/app/submission/submit/submission-submit.component.spec.ts index da569e4e5d7..d54eaaeccb7 100644 --- a/src/app/submission/submit/submission-submit.component.spec.ts +++ b/src/app/submission/submit/submission-submit.component.spec.ts @@ -1,22 +1,34 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { + NO_ERRORS_SCHEMA, + ViewContainerRef, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core'; - +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { mockSubmissionObject } from '../../shared/mocks/submission.mock'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { SubmissionService } from '../submission.service'; -import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { RouterStub } from '../../shared/testing/router.stub'; -import { mockSubmissionObject } from '../../shared/mocks/submission.mock'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; +import { SubmissionService } from '../submission.service'; import { SubmissionSubmitComponent } from './submission-submit.component'; -import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; describe('SubmissionSubmitComponent Component', () => { @@ -37,9 +49,9 @@ describe('SubmissionSubmitComponent Component', () => { TranslateModule.forRoot(), RouterTestingModule.withRoutes([ { path: '', component: SubmissionSubmitComponent, pathMatch: 'full' }, - ]) + ]), + SubmissionSubmitComponent, ], - declarations: [SubmissionSubmitComponent], providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub }, @@ -47,9 +59,9 @@ describe('SubmissionSubmitComponent Component', () => { { provide: TranslateService, useValue: getMockTranslateService() }, { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, - ViewContainerRef + ViewContainerRef, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -78,11 +90,11 @@ describe('SubmissionSubmitComponent Component', () => { it('should redirect to workspaceitem edit when a not empty SubmissionObject has been retrieved',() => { - submissionServiceStub.createSubmission.and.returnValue(observableOf({ id: '1234'})); + submissionServiceStub.createSubmission.and.returnValue(observableOf({ id: '1234' })); fixture.detectChanges(); - expect(router.navigate).toHaveBeenCalledWith(['/workspaceitems', '1234', 'edit'], { replaceUrl: true}); + expect(router.navigate).toHaveBeenCalledWith(['/workspaceitems', '1234', 'edit'], { replaceUrl: true }); }); diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index a66d1e46566..c9e087f7d81 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -1,28 +1,48 @@ -import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewContainerRef } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { BehaviorSubject, Subscription } from 'rxjs'; -import { debounceTime, switchMap } from 'rxjs/operators'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewContainerRef, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + debounceTime, + switchMap, +} from 'rxjs/operators'; -import { hasValue, isEmpty, isNotEmptyOperator, isNotNull } from '../../shared/empty.util'; import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SubmissionService } from '../submission.service'; -import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; import { getAllSucceededRemoteData } from '../../core/shared/operators'; -import { RemoteData } from '../../core/data/remote-data'; -import { ItemDataService } from '../../core/data/item-data.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { + hasValue, + isEmpty, + isNotEmptyOperator, + isNotNull, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionService } from '../submission.service'; /** * This component allows to submit a new workspaceitem. */ @Component({ - selector: 'ds-submission-submit', + selector: 'ds-base-submission-submit', styleUrls: ['./submission-submit.component.scss'], - templateUrl: './submission-submit.component.html' + templateUrl: './submission-submit.component.html', + standalone: true, }) export class SubmissionSubmitComponent implements OnDestroy, OnInit { @@ -120,14 +140,14 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); this.router.navigate(['/mydspace']); } else { - this.router.navigate(['/workspaceitems', submissionObject.id, 'edit'], { replaceUrl: true}); + this.router.navigate(['/workspaceitems', submissionObject.id, 'edit'], { replaceUrl: true }); } } }), this.itemLink$.pipe( isNotEmptyOperator(), switchMap((itemLink: string) => - this.itemDataService.findByHref(itemLink) + this.itemDataService.findByHref(itemLink), ), getAllSucceededRemoteData(), // Multiple sources can update the item in quick succession. @@ -136,7 +156,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { ).subscribe((itemRd: RemoteData) => { this.item = itemRd.payload; this.changeDetectorRef.detectChanges(); - }) + }), ); } diff --git a/src/app/submission/submit/themed-submission-submit.component.ts b/src/app/submission/submit/themed-submission-submit.component.ts index 328113252f9..ee5ee74746e 100644 --- a/src/app/submission/submit/themed-submission-submit.component.ts +++ b/src/app/submission/submit/themed-submission-submit.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { SubmissionSubmitComponent } from './submission-submit.component'; @@ -6,9 +7,11 @@ import { SubmissionSubmitComponent } from './submission-submit.component'; * Themed wrapper for SubmissionSubmitComponent */ @Component({ - selector: 'ds-themed-submission-submit', + selector: 'ds-submission-submit', styleUrls: [], - templateUrl: './../../shared/theme-support/themed.component.html' + templateUrl: './../../shared/theme-support/themed.component.html', + standalone: true, + imports: [SubmissionSubmitComponent], }) export class ThemedSubmissionSubmitComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/submission/utils/parseSectionErrorPaths.ts b/src/app/submission/utils/parseSectionErrorPaths.ts index 4c973dedcff..b6c818f919f 100644 --- a/src/app/submission/utils/parseSectionErrorPaths.ts +++ b/src/app/submission/utils/parseSectionErrorPaths.ts @@ -39,21 +39,21 @@ const parseSectionErrorPaths = (path: string | string[]): SectionErrorPath[] => const paths = typeof path === 'string' ? [path] : path; return paths.map((item) => { - if (item.match(regex) && item.match(regex).length > 2) { - return { - sectionId: item.match(regex)[1], - fieldId: item.match(regex)[2], - fieldIndex: hasValue(item.match(regex)[3]) ? +item.match(regex)[3] : 0, - originalPath: item, - }; - } else { - return { - sectionId: item.match(regexShort)[1], - originalPath: item, - }; - } - + if (item.match(regex) && item.match(regex).length > 2) { + return { + sectionId: item.match(regex)[1], + fieldId: item.match(regex)[2], + fieldIndex: hasValue(item.match(regex)[3]) ? +item.match(regex)[3] : 0, + originalPath: item, + }; + } else { + return { + sectionId: item.match(regexShort)[1], + originalPath: item, + }; } + + }, ); }; diff --git a/src/app/submission/utils/parseSectionErrors.ts b/src/app/submission/utils/parseSectionErrors.ts index 5f2867c8b85..e2e405915d9 100644 --- a/src/app/submission/utils/parseSectionErrors.ts +++ b/src/app/submission/utils/parseSectionErrors.ts @@ -1,5 +1,8 @@ import { SubmissionObjectError } from '../../core/submission/models/submission-object.model'; -import { default as parseSectionErrorPaths, SectionErrorPath } from './parseSectionErrorPaths'; +import { + default as parseSectionErrorPaths, + SectionErrorPath, +} from './parseSectionErrorPaths'; /** * the following method accept an array of SubmissionObjectError and return a section errors object @@ -13,7 +16,7 @@ const parseSectionErrors = (errors: SubmissionObjectError[] = []): any => { const paths: SectionErrorPath[] = parseSectionErrorPaths(error.paths); paths.forEach((path: SectionErrorPath) => { - const sectionError = {path: path.originalPath, message: error.message}; + const sectionError = { path: path.originalPath, message: error.message }; if (!errorsList[path.sectionId]) { errorsList[path.sectionId] = []; } diff --git a/src/app/submit-page/submit-page-routes.ts b/src/app/submit-page/submit-page-routes.ts new file mode 100644 index 00000000000..338a81af3ed --- /dev/null +++ b/src/app/submit-page/submit-page-routes.ts @@ -0,0 +1,18 @@ +import { Route } from '@angular/router'; + +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ThemedSubmissionSubmitComponent } from '../submission/submit/themed-submission-submit.component'; + +export const ROUTES: Route[] = [ + { + canActivate: [authenticatedGuard], + path: '', + pathMatch: 'full', + component: ThemedSubmissionSubmitComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'submission.submit.title', breadcrumbKey: 'submission.submit' }, + }, +]; diff --git a/src/app/submit-page/submit-page-routing.module.ts b/src/app/submit-page/submit-page-routing.module.ts deleted file mode 100644 index 1572580b6a4..00000000000 --- a/src/app/submit-page/submit-page-routing.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { ThemedSubmissionSubmitComponent } from '../submission/submit/themed-submission-submit.component'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - canActivate: [AuthenticatedGuard], - path: '', - pathMatch: 'full', - component: ThemedSubmissionSubmitComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'submission.submit.title', breadcrumbKey: 'submission.submit' } - } - ]) - ] -}) -/** - * This module defines the default component to load when navigating to the submit page path. - */ -export class SubmitPageRoutingModule { } diff --git a/src/app/submit-page/submit-page.module.ts b/src/app/submit-page/submit-page.module.ts deleted file mode 100644 index 6942628e9ed..00000000000 --- a/src/app/submit-page/submit-page.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { SharedModule } from '../shared/shared.module'; -import { SubmitPageRoutingModule } from './submit-page-routing.module'; -import { SubmissionModule } from '../submission/submission.module'; -import { FormModule } from '../shared/form/form.module'; - -@NgModule({ - imports: [ - SubmitPageRoutingModule, - CommonModule, - SharedModule, - SubmissionModule, - FormModule, - ], -}) -/** - * This module handles all modules that need to access the submit page. - */ -export class SubmitPageModule { - -} diff --git a/src/app/subscriptions-page/subscriptions-page-routes.ts b/src/app/subscriptions-page/subscriptions-page-routes.ts new file mode 100644 index 00000000000..0dbdad2f020 --- /dev/null +++ b/src/app/subscriptions-page/subscriptions-page-routes.ts @@ -0,0 +1,18 @@ +import { Route } from '@angular/router'; + +import { SubscriptionsPageComponent } from './subscriptions-page.component'; + +export const ROUTES: Route[] = [ + { + path: '', + data: { + title: 'subscriptions.title', + }, + children: [ + { + path: '', + component: SubscriptionsPageComponent, + }, + ], + }, +]; diff --git a/src/app/subscriptions-page/subscriptions-page-routing.module.ts b/src/app/subscriptions-page/subscriptions-page-routing.module.ts deleted file mode 100644 index 149c9a415fc..00000000000 --- a/src/app/subscriptions-page/subscriptions-page-routing.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { SubscriptionsPageModule } from './subscriptions-page.module'; -import { SubscriptionsPageComponent } from './subscriptions-page.component'; - - -@NgModule({ - imports: [ - SubscriptionsPageModule, - RouterModule.forChild([ - { - path: '', - data: { - title: 'subscriptions.title', - }, - children: [ - { - path: '', - component: SubscriptionsPageComponent, - }, - ] - }, - ]) - ] -}) -export class SubscriptionsPageRoutingModule { -} diff --git a/src/app/subscriptions-page/subscriptions-page.component.html b/src/app/subscriptions-page/subscriptions-page.component.html index ed31e16759b..e8dfc8fc6ac 100644 --- a/src/app/subscriptions-page/subscriptions-page.component.html +++ b/src/app/subscriptions-page/subscriptions-page.component.html @@ -1,13 +1,13 @@
-

{{'subscriptions.title' | translate}}

+

{{'subscriptions.title' | translate}}

- + - {{'subscriptions.title' | translate}}
- + {{ 'subscriptions.table.empty.message' | translate }} diff --git a/src/app/subscriptions-page/subscriptions-page.component.spec.ts b/src/app/subscriptions-page/subscriptions-page.component.spec.ts index 4f443924281..cd582ddf414 100644 --- a/src/app/subscriptions-page/subscriptions-page.component.spec.ts +++ b/src/app/subscriptions-page/subscriptions-page.component.spec.ts @@ -1,31 +1,46 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { BrowserModule, By } from '@angular/platform-browser'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { SubscriptionsPageComponent } from './subscriptions-page.component'; +import { AuthService } from '../core/auth/auth.service'; +import { buildPaginatedList } from '../core/data/paginated-list.model'; import { PaginationService } from '../core/pagination/pagination.service'; +import { PageInfo } from '../core/shared/page-info.model'; +import { AlertComponent } from '../shared/alert/alert.component'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { PaginationComponent } from '../shared/pagination/pagination.component'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { SubscriptionViewComponent } from '../shared/subscriptions/subscription-view/subscription-view.component'; import { SubscriptionsDataService } from '../shared/subscriptions/subscriptions-data.service'; import { PaginationServiceStub } from '../shared/testing/pagination-service.stub'; -import { AuthService } from '../core/auth/auth.service'; -import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; import { mockSubscriptionEperson, subscriptionMock, - subscriptionMock2 + subscriptionMock2, } from '../shared/testing/subscriptions-data.mock'; -import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; import { VarDirective } from '../shared/utils/var.directive'; -import { SubscriptionViewComponent } from '../shared/subscriptions/subscription-view/subscription-view.component'; -import { PageInfo } from '../core/shared/page-info.model'; -import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { buildPaginatedList } from '../core/data/paginated-list.model'; +import { SubscriptionsPageComponent } from './subscriptions-page.component'; describe('SubscriptionsPageComponent', () => { let component: SubscriptionsPageComponent; @@ -33,11 +48,11 @@ describe('SubscriptionsPageComponent', () => { let de: DebugElement; const authServiceStub = jasmine.createSpyObj('authorizationService', { - getAuthenticatedUserFromStore: observableOf(mockSubscriptionEperson) + getAuthenticatedUserFromStore: observableOf(mockSubscriptionEperson), }); const subscriptionServiceStub = jasmine.createSpyObj('SubscriptionsDataService', { - findByEPerson: jasmine.createSpy('findByEPerson') + findByEPerson: jasmine.createSpy('findByEPerson'), }); const paginationService = new PaginationServiceStub(); @@ -45,11 +60,11 @@ describe('SubscriptionsPageComponent', () => { const mockSubscriptionList = [subscriptionMock, subscriptionMock2]; const emptyPageInfo = Object.assign(new PageInfo(), { - totalElements: 0 + totalElements: 0, }); const pageInfo = Object.assign(new PageInfo(), { - totalElements: 2 + totalElements: 2, }); beforeEach(waitForAsync(() => { @@ -61,20 +76,25 @@ describe('SubscriptionsPageComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), - NoopAnimationsModule + NoopAnimationsModule, + SubscriptionsPageComponent, SubscriptionViewComponent, VarDirective, ], - declarations: [SubscriptionsPageComponent, SubscriptionViewComponent, VarDirective], providers: [ { provide: SubscriptionsDataService, useValue: subscriptionServiceStub }, { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, { provide: AuthService, useValue: authServiceStub }, - { provide: PaginationService, useValue: paginationService } + { provide: PaginationService, useValue: paginationService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) + .overrideComponent(SubscriptionsPageComponent, { + remove: { + imports: [ThemedLoadingComponent, PaginationComponent, AlertComponent], + }, + }) .compileComponents(); })); diff --git a/src/app/subscriptions-page/subscriptions-page.component.ts b/src/app/subscriptions-page/subscriptions-page.component.ts index 05c587ba12c..5d0b4b258d8 100644 --- a/src/app/subscriptions-page/subscriptions-page.component.ts +++ b/src/app/subscriptions-page/subscriptions-page.component.ts @@ -1,24 +1,55 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { + AsyncPipe, + NgFor, + NgIf, +} from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatestWith, + Observable, + shareReplay, + Subscription as rxjsSubscription, +} from 'rxjs'; +import { + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; -import { BehaviorSubject, combineLatestWith, Observable, shareReplay, Subscription as rxjsSubscription } from 'rxjs'; -import { map, switchMap, take, tap } from 'rxjs/operators'; - -import { Subscription } from '../shared/subscriptions/models/subscription.model'; -import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; -import { SubscriptionsDataService } from '../shared/subscriptions/subscriptions-data.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { PaginationService } from '../core/pagination/pagination.service'; -import { PageInfo } from '../core/shared/page-info.model'; import { AuthService } from '../core/auth/auth.service'; +import { + buildPaginatedList, + PaginatedList, +} from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; import { EPerson } from '../core/eperson/models/eperson.model'; +import { PaginationService } from '../core/pagination/pagination.service'; import { getAllCompletedRemoteData } from '../core/shared/operators'; -import { RemoteData } from '../core/data/remote-data'; +import { PageInfo } from '../core/shared/page-info.model'; +import { AlertComponent } from '../shared/alert/alert.component'; +import { AlertType } from '../shared/alert/alert-type'; import { hasValue } from '../shared/empty.util'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { PaginationComponent } from '../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { Subscription } from '../shared/subscriptions/models/subscription.model'; +import { SubscriptionViewComponent } from '../shared/subscriptions/subscription-view/subscription-view.component'; +import { SubscriptionsDataService } from '../shared/subscriptions/subscriptions-data.service'; +import { VarDirective } from '../shared/utils/var.directive'; @Component({ selector: 'ds-subscriptions-page', templateUrl: './subscriptions-page.component.html', - styleUrls: ['./subscriptions-page.component.scss'] + styleUrls: ['./subscriptions-page.component.scss'], + standalone: true, + imports: [NgIf, ThemedLoadingComponent, VarDirective, PaginationComponent, NgFor, SubscriptionViewComponent, AlertComponent, AsyncPipe, TranslateModule], }) /** * List and allow to manage all the active subscription for the current user @@ -36,7 +67,7 @@ export class SubscriptionsPageComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'elp', pageSize: 10, - currentPage: 1 + currentPage: 1, }); /** @@ -54,10 +85,12 @@ export class SubscriptionsPageComponent implements OnInit, OnDestroy { */ sub: rxjsSubscription = null; + readonly AlertType = AlertType; + constructor( private paginationService: PaginationService, private authService: AuthService, - private subscriptionService: SubscriptionsDataService + private subscriptionService: SubscriptionsDataService, ) { } @@ -69,7 +102,7 @@ export class SubscriptionsPageComponent implements OnInit, OnDestroy { this.ePersonId$ = this.authService.getAuthenticatedUserFromStore().pipe( take(1), map((ePerson: EPerson) => ePerson.id), - shareReplay() + shareReplay({ refCount: false }), ); this.retrieveSubscriptions(); } @@ -85,9 +118,9 @@ export class SubscriptionsPageComponent implements OnInit, OnDestroy { tap(() => this.loading$.next(true)), switchMap(([currentPagination, ePersonId]) => this.subscriptionService.findByEPerson(ePersonId,{ currentPage: currentPagination.currentPage, - elementsPerPage: currentPagination.pageSize + elementsPerPage: currentPagination.pageSize, })), - getAllCompletedRemoteData() + getAllCompletedRemoteData(), ).subscribe((res: RemoteData>) => { if (res.hasSucceeded) { this.subscriptions$.next(res.payload); diff --git a/src/app/subscriptions-page/subscriptions-page.module.ts b/src/app/subscriptions-page/subscriptions-page.module.ts deleted file mode 100644 index f7a4dc33446..00000000000 --- a/src/app/subscriptions-page/subscriptions-page.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { SubscriptionsPageComponent } from './subscriptions-page.component'; -import { SharedModule } from '../shared/shared.module'; -import { SubscriptionsModule } from '../shared/subscriptions/subscriptions.module'; - -@NgModule({ - declarations: [SubscriptionsPageComponent], - imports: [ - CommonModule, - SharedModule, - SubscriptionsModule, - ] -}) -export class SubscriptionsPageModule { } diff --git a/src/app/suggestions-page/suggestions-page-routes.ts b/src/app/suggestions-page/suggestions-page-routes.ts new file mode 100644 index 00000000000..f270a1ef663 --- /dev/null +++ b/src/app/suggestions-page/suggestions-page-routes.ts @@ -0,0 +1,24 @@ +import { Route } from '@angular/router'; + +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { publicationClaimBreadcrumbResolver } from '../core/breadcrumbs/publication-claim-breadcrumb.resolver'; +import { SuggestionsPageComponent } from './suggestions-page.component'; +import { suggestionsPageResolver } from './suggestions-page.resolver'; + +export const ROUTES: Route[] = [ + { + path: ':targetId', + resolve: { + suggestionTargets: suggestionsPageResolver, + breadcrumb: publicationClaimBreadcrumbResolver,//i18nBreadcrumbResolver + }, + data: { + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', + showBreadcrumbsFluid: false, + }, + canActivate: [authenticatedGuard], + runGuardsAndResolvers: 'always', + component: SuggestionsPageComponent, + }, +]; diff --git a/src/app/suggestions-page/suggestions-page-routing-paths.ts b/src/app/suggestions-page/suggestions-page-routing-paths.ts new file mode 100644 index 00000000000..0f3aa782d21 --- /dev/null +++ b/src/app/suggestions-page/suggestions-page-routing-paths.ts @@ -0,0 +1,11 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; + +export const SUGGESTION_MODULE_PATH = 'suggestions'; + +export function getSuggestionModuleRoute() { + return `/${SUGGESTION_MODULE_PATH}`; +} + +export function getSuggestionPageRoute(SuggestionId: string) { + return new URLCombiner(getSuggestionModuleRoute(), SuggestionId).toString(); +} diff --git a/src/app/suggestions-page/suggestions-page.component.html b/src/app/suggestions-page/suggestions-page.component.html new file mode 100644 index 00000000000..45bdff32bf5 --- /dev/null +++ b/src/app/suggestions-page/suggestions-page.component.html @@ -0,0 +1,50 @@ +
+
+
+ + +
+ +

+ {{'suggestion.suggestionFor' | translate}} + {{researcherName}} + {{'suggestion.from.source' | translate}} {{ translateSuggestionSource() | translate }} +

+ +
+ + ({{ getSelectedSuggestionsCount() }}) + + +
+ +
    +
  • + +
  • +
+
+
+ + {{'suggestion.count.missing' | translate}} + +
+
+
+
diff --git a/src/themes/custom/app/workspace-items-delete-page/workspace-items-delete/workspace-items-delete.component.html b/src/app/suggestions-page/suggestions-page.component.scss similarity index 100% rename from src/themes/custom/app/workspace-items-delete-page/workspace-items-delete/workspace-items-delete.component.html rename to src/app/suggestions-page/suggestions-page.component.scss diff --git a/src/app/suggestions-page/suggestions-page.component.spec.ts b/src/app/suggestions-page/suggestions-page.component.spec.ts new file mode 100644 index 00000000000..6c19405e943 --- /dev/null +++ b/src/app/suggestions-page/suggestions-page.component.spec.ts @@ -0,0 +1,228 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { AuthService } from '../core/auth/auth.service'; +import { PaginationService } from '../core/pagination/pagination.service'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { SuggestionApproveAndImport } from '../notifications/suggestion-list-element/suggestion-approve-and-import'; +import { SuggestionEvidencesComponent } from '../notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; +import { SuggestionListElementComponent } from '../notifications/suggestion-list-element/suggestion-list-element.component'; +import { SuggestionTargetsStateService } from '../notifications/suggestion-targets/suggestion-targets.state.service'; +import { SuggestionsService } from '../notifications/suggestions.service'; +import { + mockSuggestionPublicationOne, + mockSuggestionPublicationTwo, +} from '../shared/mocks/publication-claim.mock'; +import { mockSuggestionTargetsObjectOne } from '../shared/mocks/publication-claim-targets.mock'; +import { + getMockSuggestionNotificationsStateService, + getMockSuggestionsService, +} from '../shared/mocks/suggestion.mock'; +import { getMockTranslateService } from '../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; +import { PaginationServiceStub } from '../shared/testing/pagination-service.stub'; +import { RouterStub } from '../shared/testing/router.stub'; +import { ObjectKeysPipe } from '../shared/utils/object-keys-pipe'; +import { VarDirective } from '../shared/utils/var.directive'; +import { SuggestionsPageComponent } from './suggestions-page.component'; + +describe('SuggestionPageComponent', () => { + let component: SuggestionsPageComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + const mockSuggestionsService = getMockSuggestionsService(); + const mockSuggestionsTargetStateService = getMockSuggestionNotificationsStateService(); + const router = new RouterStub(); + const routeStub = { + data: observableOf({ + suggestionTargets: createSuccessfulRemoteDataObject(mockSuggestionTargetsObjectOne), + }), + queryParams: observableOf({}), + }; + const workspaceitemServiceMock = jasmine.createSpyObj('WorkspaceitemDataService', { + importExternalSourceEntry: jasmine.createSpy('importExternalSourceEntry'), + }); + + const authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {}, + }); + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + TranslateModule.forRoot(), + SuggestionEvidencesComponent, + SuggestionListElementComponent, + SuggestionsPageComponent, + ObjectKeysPipe, + VarDirective, + ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: WorkspaceitemDataService, useValue: workspaceitemServiceMock }, + { provide: Router, useValue: router }, + { provide: SuggestionsService, useValue: mockSuggestionsService }, + { provide: SuggestionTargetsStateService, useValue: mockSuggestionsTargetStateService }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: PaginationService, useValue: paginationService }, + SuggestionsPageComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .compileComponents().then(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SuggestionsPageComponent); + component = fixture.componentInstance; + scheduler = getTestScheduler(); + }); + + it('should create', () => { + spyOn(component, 'updatePage').and.callThrough(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect(component).toBeTruthy(); + expect(component.suggestionId).toBe(mockSuggestionTargetsObjectOne.id); + expect(component.researcherName).toBe(mockSuggestionTargetsObjectOne.display); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should update page on pagination change', () => { + spyOn(component, 'updatePage').and.callThrough(); + component.targetId$ = observableOf('testid'); + + scheduler.schedule(() => component.onPaginationChange()); + scheduler.flush(); + + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should update suggestion on page update', () => { + spyOn(component.processing$, 'next'); + spyOn(component.suggestionsRD$, 'next'); + + component.targetId$ = observableOf('testid'); + scheduler.schedule(() => component.updatePage().subscribe()); + scheduler.flush(); + + expect(component.processing$.next).toHaveBeenCalledTimes(2); + expect(mockSuggestionsService.getSuggestions).toHaveBeenCalled(); + expect(component.suggestionsRD$.next).toHaveBeenCalled(); + expect(mockSuggestionsService.clearSuggestionRequests).toHaveBeenCalled(); + }); + + it('should flag suggestion for deletion', fakeAsync(() => { + spyOn(component, 'updatePage').and.callThrough(); + component.targetId$ = observableOf('testid'); + + scheduler.schedule(() => component.ignoreSuggestion('1')); + scheduler.flush(); + + expect(mockSuggestionsService.ignoreSuggestion).toHaveBeenCalledWith('1'); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + })); + + it('should flag all suggestion for deletion', () => { + spyOn(component, 'updatePage').and.callThrough(); + component.targetId$ = observableOf('testid'); + + scheduler.schedule(() => component.ignoreSuggestionAllSelected()); + scheduler.flush(); + + expect(mockSuggestionsService.ignoreSuggestionMultiple).toHaveBeenCalled(); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should approve and import', () => { + spyOn(component, 'updatePage').and.callThrough(); + component.targetId$ = observableOf('testid'); + + scheduler.schedule(() => component.approveAndImport({ collectionId: '1234' } as unknown as SuggestionApproveAndImport)); + scheduler.flush(); + + expect(mockSuggestionsService.approveAndImport).toHaveBeenCalled(); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should approve and import multiple suggestions', () => { + spyOn(component, 'updatePage').and.callThrough(); + component.targetId$ = observableOf('testid'); + + scheduler.schedule(() => component.approveAndImportAllSelected({ collectionId: '1234' } as unknown as SuggestionApproveAndImport)); + scheduler.flush(); + + expect(mockSuggestionsService.approveAndImportMultiple).toHaveBeenCalled(); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should select and deselect suggestion', () => { + component.selectedSuggestions = {}; + component.onSelected(mockSuggestionPublicationOne, true); + expect(component.selectedSuggestions[mockSuggestionPublicationOne.id]).toBe(mockSuggestionPublicationOne); + component.onSelected(mockSuggestionPublicationOne, false); + expect(component.selectedSuggestions[mockSuggestionPublicationOne.id]).toBeUndefined(); + }); + + it('should toggle all suggestions', () => { + component.selectedSuggestions = {}; + component.onToggleSelectAll([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(component.selectedSuggestions[mockSuggestionPublicationOne.id]).toEqual(mockSuggestionPublicationOne); + expect(component.selectedSuggestions[mockSuggestionPublicationTwo.id]).toEqual(mockSuggestionPublicationTwo); + component.onToggleSelectAll([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(component.selectedSuggestions).toEqual({}); + }); + + it('should return all selected suggestions count', () => { + component.selectedSuggestions = {}; + component.onToggleSelectAll([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(component.getSelectedSuggestionsCount()).toEqual(2); + }); + + it('should check if all collection is fixed', () => { + component.isCollectionFixed([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(mockSuggestionsService.isCollectionFixed).toHaveBeenCalled(); + }); + + it('should translate suggestion source', () => { + component.translateSuggestionSource(); + expect(mockSuggestionsService.translateSuggestionSource).toHaveBeenCalled(); + }); + + it('should translate suggestion type', () => { + component.translateSuggestionType(); + expect(mockSuggestionsService.translateSuggestionType).toHaveBeenCalled(); + }); +}); diff --git a/src/app/suggestions-page/suggestions-page.component.ts b/src/app/suggestions-page/suggestions-page.component.ts new file mode 100644 index 00000000000..0fc790a125d --- /dev/null +++ b/src/app/suggestions-page/suggestions-page.component.ts @@ -0,0 +1,345 @@ +import { + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Data, + Router, + RouterLink, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + Observable, +} from 'rxjs'; +import { + distinctUntilChanged, + map, + switchMap, + tap, +} from 'rxjs/operators'; + +import { AuthService } from '../core/auth/auth.service'; +import { + SortDirection, + SortOptions, +} from '../core/cache/models/sort-options.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { PaginatedList } from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; +import { Suggestion } from '../core/notifications/suggestions/models/suggestion.model'; +import { SuggestionTarget } from '../core/notifications/suggestions/models/suggestion-target.model'; +import { PaginationService } from '../core/pagination/pagination.service'; +import { redirectOn4xx } from '../core/shared/authorized.operators'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../core/shared/operators'; +import { WorkspaceItem } from '../core/submission/models/workspaceitem.model'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { SuggestionActionsComponent } from '../notifications/suggestion-actions/suggestion-actions.component'; +import { SuggestionApproveAndImport } from '../notifications/suggestion-list-element/suggestion-approve-and-import'; +import { SuggestionListElementComponent } from '../notifications/suggestion-list-element/suggestion-list-element.component'; +import { SuggestionTargetsStateService } from '../notifications/suggestion-targets/suggestion-targets.state.service'; +import { + SuggestionBulkResult, + SuggestionsService, +} from '../notifications/suggestions.service'; +import { AlertComponent } from '../shared/alert/alert.component'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { PaginationComponent } from '../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { VarDirective } from '../shared/utils/var.directive'; +import { getWorkspaceItemEditRoute } from '../workflowitems-edit-page/workflowitems-edit-page-routing-paths'; + +@Component({ + selector: 'ds-suggestion-page', + templateUrl: './suggestions-page.component.html', + styleUrls: ['./suggestions-page.component.scss'], + imports: [ + AsyncPipe, + VarDirective, + NgIf, + RouterLink, + TranslateModule, + SuggestionActionsComponent, + ThemedLoadingComponent, + PaginationComponent, + SuggestionListElementComponent, + NgForOf, + AlertComponent, + ], + standalone: true, +}) + +/** + * Component used to visualize one of the suggestions from the publication claim page or from the notification pop up + */ + +export class SuggestionsPageComponent implements OnInit { + + /** + * The pagination configuration + */ + paginationOptions: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'sp', + pageSizeOptions: [5, 10, 20, 40, 60], + }); + + /** + * The sorting configuration + */ + paginationSortConfig: SortOptions = new SortOptions('trust', SortDirection.DESC); + + /** + * The FindListOptions object + */ + defaultConfig: FindListOptions = Object.assign(new FindListOptions(), { sort: this.paginationSortConfig }); + + /** + * A boolean representing if results are loading + */ + public processing$ = new BehaviorSubject(false); + + /** + * A list of remote data objects of suggestions + */ + suggestionsRD$: BehaviorSubject> = new BehaviorSubject>({} as any); + + targetRD$: Observable>; + targetId$: Observable; + + suggestionTarget: SuggestionTarget; + suggestionId: any; + suggestionSource: any; + researcherName: any; + researcherUuid: any; + + selectedSuggestions: { [id: string]: Suggestion } = {}; + isBulkOperationPending = false; + + constructor( + private authService: AuthService, + private notificationService: NotificationsService, + private paginationService: PaginationService, + private route: ActivatedRoute, + private router: Router, + private suggestionService: SuggestionsService, + private suggestionTargetsStateService: SuggestionTargetsStateService, + private translateService: TranslateService, + private workspaceItemService: WorkspaceitemDataService, + ) { + } + + ngOnInit(): void { + this.targetRD$ = this.route.data.pipe( + map((data: Data) => data.suggestionTargets as RemoteData), + redirectOn4xx(this.router, this.authService), + ); + + this.targetId$ = this.targetRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((target: SuggestionTarget) => target.id), + ); + this.targetRD$.pipe( + getFirstSucceededRemoteDataPayload(), + tap((suggestionTarget: SuggestionTarget) => { + this.suggestionTarget = suggestionTarget; + this.suggestionId = suggestionTarget.id; + this.researcherName = suggestionTarget.display; + this.suggestionSource = suggestionTarget.source; + this.researcherUuid = this.suggestionService.getTargetUuid(suggestionTarget); + }), + switchMap(() => this.updatePage()), + ).subscribe(); + + this.suggestionTargetsStateService.dispatchMarkUserSuggestionsAsVisitedAction(); + } + + /** + * Called when one of the pagination settings is changed + */ + onPaginationChange() { + this.updatePage().subscribe(); + } + + /** + * Update the list of suggestions + */ + updatePage(): Observable>> { + this.processing$.next(true); + const pageConfig$: Observable = this.paginationService.getFindListOptions( + this.paginationOptions.id, + this.defaultConfig, + ).pipe( + distinctUntilChanged(), + ); + + return combineLatest([this.targetId$, pageConfig$]).pipe( + switchMap(([targetId, config]: [string, FindListOptions]) => { + return this.suggestionService.getSuggestions( + targetId, + config.elementsPerPage, + config.currentPage, + config.sort, + ); + }), + getFirstCompletedRemoteData(), + tap((resultsRD: RemoteData>) => { + this.processing$.next(false); + if (resultsRD.hasSucceeded) { + this.suggestionsRD$.next(resultsRD.payload); + } else { + this.suggestionsRD$.next(null); + } + + this.suggestionService.clearSuggestionRequests(); + }), + ); + } + + /** + * Used to delete a suggestion. + * @suggestionId + */ + ignoreSuggestion(suggestionId) { + this.suggestionService.ignoreSuggestion(suggestionId).pipe( + tap(() => this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction()), + switchMap(() => this.updatePage()), + ).subscribe(); + } + + /** + * Used to delete all selected suggestions. + */ + ignoreSuggestionAllSelected() { + this.isBulkOperationPending = true; + this.suggestionService.ignoreSuggestionMultiple(Object.values(this.selectedSuggestions)).pipe( + tap((results: SuggestionBulkResult) => { + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + this.isBulkOperationPending = false; + this.selectedSuggestions = {}; + if (results.success > 0) { + this.notificationService.success( + this.translateService.get('suggestion.ignoreSuggestion.bulk.success', + { count: results.success })); + } + if (results.fails > 0) { + this.notificationService.error( + this.translateService.get('suggestion.ignoreSuggestion.bulk.error', + { count: results.fails })); + } + }), + switchMap(() => this.updatePage()), + ).subscribe(); + } + + /** + * Used to approve & import. + * @param event contains the suggestion and the target collection + */ + approveAndImport(event: SuggestionApproveAndImport) { + this.suggestionService.approveAndImport(this.workspaceItemService, event.suggestion, event.collectionId).pipe( + tap((workspaceitem: WorkspaceItem) => { + const content = this.translateService.instant('suggestion.approveAndImport.success', { url: getWorkspaceItemEditRoute(workspaceitem.id) }); + this.notificationService.success('', content, { timeOut:0 }, true); + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + }), + switchMap(() => this.updatePage()), + ).subscribe(); + } + + /** + * Used to approve & import all selected suggestions. + * @param event contains the target collection + */ + approveAndImportAllSelected(event: SuggestionApproveAndImport) { + this.isBulkOperationPending = true; + this.suggestionService.approveAndImportMultiple(this.workspaceItemService, Object.values(this.selectedSuggestions), event.collectionId).pipe( + tap((results: SuggestionBulkResult) => { + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + this.isBulkOperationPending = false; + this.selectedSuggestions = {}; + if (results.success > 0) { + this.notificationService.success( + this.translateService.get('suggestion.approveAndImport.bulk.success', + { count: results.success })); + } + if (results.fails > 0) { + this.notificationService.error( + this.translateService.get('suggestion.approveAndImport.bulk.error', + { count: results.fails })); + } + }), + switchMap(() => this.updatePage()), + ).subscribe(); + } + + /** + * When a specific suggestion is selected. + * @param object the suggestions + * @param selected the new selected value for the suggestion + */ + onSelected(object: Suggestion, selected: boolean) { + if (selected) { + this.selectedSuggestions[object.id] = object; + } else { + delete this.selectedSuggestions[object.id]; + } + } + + /** + * When Toggle Select All occurs. + * @param suggestions all the visible suggestions inside the page + */ + onToggleSelectAll(suggestions: Suggestion[]) { + if ( this.getSelectedSuggestionsCount() > 0) { + this.selectedSuggestions = {}; + } else { + suggestions.forEach((suggestion) => { + this.selectedSuggestions[suggestion.id] = suggestion; + }); + } + } + + /** + * The current number of selected suggestions. + */ + getSelectedSuggestionsCount(): number { + return Object.keys(this.selectedSuggestions).length; + } + + /** + * Return true if all the suggestion are configured with the same fixed collection in the configuration. + * @param suggestions + */ + isCollectionFixed(suggestions: Suggestion[]): boolean { + return this.suggestionService.isCollectionFixed(suggestions); + } + + /** + * Label to be used to translate the suggestion source. + */ + translateSuggestionSource() { + return this.suggestionService.translateSuggestionSource(this.suggestionSource); + } + + /** + * Label to be used to translate the suggestion type. + */ + translateSuggestionType() { + return this.suggestionService.translateSuggestionType(this.suggestionSource); + } + +} diff --git a/src/app/suggestions-page/suggestions-page.resolver.ts b/src/app/suggestions-page/suggestions-page.resolver.ts new file mode 100644 index 00000000000..1314846b6e6 --- /dev/null +++ b/src/app/suggestions-page/suggestions-page.resolver.ts @@ -0,0 +1,30 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { RemoteData } from '../core/data/remote-data'; +import { SuggestionTarget } from '../core/notifications/suggestions/models/suggestion-target.model'; +import { SuggestionTargetDataService } from '../core/notifications/suggestions/target/suggestion-target-data.service'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; + +/** + * Method for resolving a suggestion target based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {SuggestionTargetDataService} suggestionsDataService + * @returns Observable<> Emits the found collection based on the parameters in the current route, + * or an error if something went wrong + */ +export const suggestionsPageResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + suggestionsDataService: SuggestionTargetDataService = inject(SuggestionTargetDataService), +): Observable> => { + return suggestionsDataService.getTargetById(route.params.targetId).pipe( + getFirstCompletedRemoteData(), + ); +}; diff --git a/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.html b/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.html index 5f741091f1e..c2c1942cdd3 100644 --- a/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.html +++ b/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.html @@ -1,27 +1,21 @@
-
+
- - {{'system-wide-alert-banner.countdown.prefix' | translate }} - - - {{'system-wide-alert-banner.countdown.days' | translate: { - days: countDownDays|async - } }} - - - {{'system-wide-alert-banner.countdown.hours' | translate: { - hours: countDownHours| async - } }} - - - {{'system-wide-alert-banner.countdown.minutes' | translate: { - minutes: countDownMinutes|async - } }} - + + {{ 'system-wide-alert-banner.countdown.prefix' | translate }} + + + {{ 'system-wide-alert-banner.countdown.days' | translate: { days: countDownDays|async } }} + + + {{ 'system-wide-alert-banner.countdown.hours' | translate: { hours: countDownHours| async } }} + + + {{ 'system-wide-alert-banner.countdown.minutes' | translate: { minutes: countDownMinutes|async } }} + - {{(systemWideAlert$ |async)?.message}} +
-
+
\ No newline at end of file diff --git a/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.spec.ts b/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.spec.ts index f767d0f196d..a1d6e3b27ee 100644 --- a/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.spec.ts +++ b/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.spec.ts @@ -1,16 +1,24 @@ -import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { SystemWideAlertBannerComponent } from './system-wide-alert-banner.component'; -import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; -import { SystemWideAlert } from '../system-wide-alert.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { utcToZonedTime } from 'date-fns-tz'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { TestScheduler } from 'rxjs/testing'; -import { getTestScheduler } from 'jasmine-marbles'; +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; +import { utcToZonedTime } from 'date-fns-tz'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { SystemWideAlert } from '../system-wide-alert.model'; +import { SystemWideAlertBannerComponent } from './system-wide-alert-banner.component'; describe('SystemWideAlertBannerComponent', () => { @@ -33,7 +41,7 @@ describe('SystemWideAlertBannerComponent', () => { alertId: 1, message: 'Test alert message', active: true, - countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString() + countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString(), }); systemWideAlertDataService = jasmine.createSpyObj('systemWideAlertDataService', { @@ -41,12 +49,11 @@ describe('SystemWideAlertBannerComponent', () => { }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [SystemWideAlertBannerComponent], + imports: [TranslateModule.forRoot(), SystemWideAlertBannerComponent], providers: [ - {provide: SystemWideAlertDataService, useValue: systemWideAlertDataService}, - {provide: NotificationsService, useValue: new NotificationsServiceStub()}, - ] + { provide: SystemWideAlertDataService, useValue: systemWideAlertDataService }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + ], }).compileComponents(); })); diff --git a/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.ts b/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.ts index 27bdb14f1d7..79ecf2117a6 100644 --- a/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.ts +++ b/src/app/system-wide-alert/alert-banner/system-wide-alert-banner.component.ts @@ -1,16 +1,38 @@ -import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; -import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; import { - getAllSucceededRemoteDataPayload -} from '../../core/shared/operators'; -import { filter, map, switchMap } from 'rxjs/operators'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { SystemWideAlert } from '../system-wide-alert.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { BehaviorSubject, EMPTY, interval, Subscription } from 'rxjs'; + AsyncPipe, + isPlatformBrowser, + NgIf, +} from '@angular/common'; +import { + Component, + Inject, + OnDestroy, + OnInit, + PLATFORM_ID, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { zonedTimeToUtc } from 'date-fns-tz'; -import { isPlatformBrowser } from '@angular/common'; +import { + BehaviorSubject, + EMPTY, + interval, + Subscription, +} from 'rxjs'; +import { + filter, + map, + switchMap, +} from 'rxjs/operators'; + +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; +import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SystemWideAlert } from '../system-wide-alert.model'; /** * Component responsible for rendering a banner and the countdown for an active system-wide alert @@ -18,7 +40,9 @@ import { NotificationsService } from '../../shared/notifications/notifications.s @Component({ selector: 'ds-system-wide-alert-banner', styleUrls: ['./system-wide-alert-banner.component.scss'], - templateUrl: './system-wide-alert-banner.component.html' + templateUrl: './system-wide-alert-banner.component.html', + standalone: true, + imports: [NgIf, AsyncPipe, TranslateModule], }) export class SystemWideAlertBannerComponent implements OnInit, OnDestroy { @@ -48,7 +72,7 @@ export class SystemWideAlertBannerComponent implements OnInit, OnDestroy { subscriptions: Subscription[] = []; constructor( - @Inject(PLATFORM_ID) protected platformId: Object, + @Inject(PLATFORM_ID) protected platformId: any, protected systemWideAlertDataService: SystemWideAlertDataService, protected notificationsService: NotificationsService, ) { @@ -59,7 +83,7 @@ export class SystemWideAlertBannerComponent implements OnInit, OnDestroy { getAllSucceededRemoteDataPayload(), map((payload: PaginatedList) => payload.page), filter((page) => isNotEmpty(page)), - map((page) => page[0]) + map((page) => page[0]), ).subscribe((alert: SystemWideAlert) => { this.systemWideAlert$.next(alert); })); @@ -84,7 +108,7 @@ export class SystemWideAlertBannerComponent implements OnInit, OnDestroy { this.countDownHours.next(0); this.countDownMinutes.next(0); return EMPTY; - }) + }), ).subscribe(() => { this.setTimeDifference(this.systemWideAlert$.getValue().countdownTo); })); diff --git a/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.html b/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.html index 169081e2777..dc2699b6d1b 100644 --- a/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.html +++ b/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.html @@ -1,27 +1,26 @@
- +

{{'system-wide-alert.form.header' | translate}}

+ [uncheckedLabel]="'system-wide-alert.form.label.inactive' | translate" + [checked]="formActive.value" (change)="setActive($event)">
- - {{ 'system-wide-alert.form.error.message' | translate }} - + class="invalid-feedback show-feedback"> + + {{ 'system-wide-alert.form.error.message' | translate }} +
@@ -29,9 +28,8 @@
- + {{ 'system-wide-alert.form.label.countdownTo.enable' | translate }}
@@ -39,18 +37,11 @@
- - + +
@@ -66,49 +57,38 @@
- {{'system-wide-alert.form.label.countdownTo.hint' | translate}} + {{ 'system-wide-alert.form.label.countdownTo.hint' | translate }}
- -
- +
- - {{'system-wide-alert-banner.countdown.prefix' | translate }} - - - {{'system-wide-alert-banner.countdown.days' | translate: { - days: previewDays - } }} - - - {{'system-wide-alert-banner.countdown.hours' | translate: { - hours: previewHours - } }} - - - {{'system-wide-alert-banner.countdown.minutes' | translate: { - minutes: previewMinutes - } }} - + + {{ 'system-wide-alert-banner.countdown.prefix' | translate }} + + + {{ 'system-wide-alert-banner.countdown.days' | translate: { days: previewDays } }} + + + {{ 'system-wide-alert-banner.countdown.hours' | translate: { hours: previewHours } }} + + + {{ 'system-wide-alert-banner.countdown.minutes' | translate: { minutes: previewMinutes } }} + - {{formMessage.value}} +
-
- - +
-
diff --git a/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.spec.ts b/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.spec.ts index 505990b4455..2616b2accf9 100644 --- a/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.spec.ts +++ b/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.spec.ts @@ -1,19 +1,29 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; -import { SystemWideAlert } from '../system-wide-alert.model'; -import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { SystemWideAlertFormComponent } from './system-wide-alert-form.component'; +import { + utcToZonedTime, + zonedTimeToUtc, +} from 'date-fns-tz'; +import { UiSwitchModule } from 'ngx-ui-switch'; + import { RequestService } from '../../core/data/request.service'; +import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { RouterStub } from '../../shared/testing/router.stub'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { Router } from '@angular/router'; -import { FormsModule } from '@angular/forms'; -import { UiSwitchModule } from 'ngx-ui-switch'; -import { SystemWideAlertModule } from '../system-wide-alert.module'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { SystemWideAlert } from '../system-wide-alert.model'; +import { SystemWideAlertFormComponent } from './system-wide-alert-form.component'; describe('SystemWideAlertFormComponent', () => { let comp: SystemWideAlertFormComponent; @@ -37,13 +47,13 @@ describe('SystemWideAlertFormComponent', () => { alertId: 1, message: 'Test alert message', active: true, - countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString() + countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString(), }); systemWideAlertDataService = jasmine.createSpyObj('systemWideAlertDataService', { findAll: createSuccessfulRemoteDataObject$(createPaginatedList([systemWideAlert])), put: createSuccessfulRemoteDataObject$(systemWideAlert), - create: createSuccessfulRemoteDataObject$(systemWideAlert) + create: createSuccessfulRemoteDataObject$(systemWideAlert), }); requestService = jasmine.createSpyObj('requestService', ['setStaleByHrefSubstring']); @@ -52,14 +62,13 @@ describe('SystemWideAlertFormComponent', () => { router = new RouterStub(); TestBed.configureTestingModule({ - imports: [FormsModule, SystemWideAlertModule, UiSwitchModule, TranslateModule.forRoot()], - declarations: [SystemWideAlertFormComponent], + imports: [FormsModule, UiSwitchModule, TranslateModule.forRoot(), SystemWideAlertFormComponent], providers: [ - {provide: SystemWideAlertDataService, useValue: systemWideAlertDataService}, - {provide: NotificationsService, useValue: notificationsService}, - {provide: Router, useValue: router}, - {provide: RequestService, useValue: requestService}, - ] + { provide: SystemWideAlertDataService, useValue: systemWideAlertDataService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: Router, useValue: router }, + { provide: RequestService, useValue: requestService }, + ], }).compileComponents(); })); @@ -90,8 +99,8 @@ describe('SystemWideAlertFormComponent', () => { comp.createForm(); expect(comp.formMessage.value).toEqual(''); expect(comp.formActive.value).toEqual(false); - expect(comp.time).toEqual({hour: now.getHours(), minute: now.getMinutes()}); - expect(comp.date).toEqual({year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate()}); + expect(comp.time).toEqual({ hour: now.getHours(), minute: now.getMinutes() }); + expect(comp.date).toEqual({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() }); }); }); @@ -103,11 +112,11 @@ describe('SystemWideAlertFormComponent', () => { expect(comp.formMessage.value).toEqual(systemWideAlert.message); expect(comp.formActive.value).toEqual(true); - expect(comp.time).toEqual({hour: countDownTo.getHours(), minute: countDownTo.getMinutes()}); + expect(comp.time).toEqual({ hour: countDownTo.getHours(), minute: countDownTo.getMinutes() }); expect(comp.date).toEqual({ year: countDownTo.getFullYear(), month: countDownTo.getMonth() + 1, - day: countDownTo.getDate() + day: countDownTo.getDate(), }); }); }); @@ -138,8 +147,8 @@ describe('SystemWideAlertFormComponent', () => { countDownDate.setHours(countDownDate.getHours() + 1); countDownDate.setMinutes(countDownDate.getMinutes() + 1); - comp.time = {hour: countDownDate.getHours(), minute: countDownDate.getMinutes()}; - comp.date = {year: countDownDate.getFullYear(), month: countDownDate.getMonth() + 1, day: countDownDate.getDate()}; + comp.time = { hour: countDownDate.getHours(), minute: countDownDate.getMinutes() }; + comp.date = { year: countDownDate.getFullYear(), month: countDownDate.getMonth() + 1, day: countDownDate.getDate() }; comp.updatePreviewTime(); @@ -167,8 +176,8 @@ describe('SystemWideAlertFormComponent', () => { comp.formMessage.patchValue('New message'); comp.formActive.patchValue(true); - comp.time = {hour: 4, minute: 26}; - comp.date = {year: 2023, month: 1, day: 25}; + comp.time = { hour: 4, minute: 26 }; + comp.date = { year: 2023, month: 1, day: 25 }; const expectedAlert = new SystemWideAlert(); expectedAlert.alertId = systemWideAlert.alertId; @@ -190,8 +199,8 @@ describe('SystemWideAlertFormComponent', () => { comp.formMessage.patchValue('New message'); comp.formActive.patchValue(true); - comp.time = {hour: 4, minute: 26}; - comp.date = {year: 2023, month: 1, day: 25}; + comp.time = { hour: 4, minute: 26 }; + comp.date = { year: 2023, month: 1, day: 25 }; const expectedAlert = new SystemWideAlert(); expectedAlert.alertId = systemWideAlert.alertId; @@ -213,8 +222,8 @@ describe('SystemWideAlertFormComponent', () => { comp.formMessage.patchValue('New message'); comp.formActive.patchValue(true); - comp.time = {hour: 4, minute: 26}; - comp.date = {year: 2023, month: 1, day: 25}; + comp.time = { hour: 4, minute: 26 }; + comp.date = { year: 2023, month: 1, day: 25 }; comp.counterEnabled$.next(false); const expectedAlert = new SystemWideAlert(); @@ -237,8 +246,8 @@ describe('SystemWideAlertFormComponent', () => { comp.formMessage.patchValue('New message'); comp.formActive.patchValue(true); - comp.time = {hour: 4, minute: 26}; - comp.date = {year: 2023, month: 1, day: 25}; + comp.time = { hour: 4, minute: 26 }; + comp.date = { year: 2023, month: 1, day: 25 }; const expectedAlert = new SystemWideAlert(); expectedAlert.alertId = systemWideAlert.alertId; @@ -260,8 +269,8 @@ describe('SystemWideAlertFormComponent', () => { comp.formMessage.patchValue('New message'); comp.formActive.patchValue(true); - comp.time = {hour: 4, minute: 26}; - comp.date = {year: 2023, month: 1, day: 25}; + comp.time = { hour: 4, minute: 26 }; + comp.date = { year: 2023, month: 1, day: 25 }; const expectedAlert = new SystemWideAlert(); expectedAlert.message = 'New message'; @@ -285,8 +294,8 @@ describe('SystemWideAlertFormComponent', () => { comp.formMessage.patchValue('New message'); comp.formActive.patchValue(true); - comp.time = {hour: 4, minute: 26}; - comp.date = {year: 2023, month: 1, day: 25}; + comp.time = { hour: 4, minute: 26 }; + comp.date = { year: 2023, month: 1, day: 25 }; const expectedAlert = new SystemWideAlert(); expectedAlert.message = 'New message'; @@ -302,6 +311,14 @@ describe('SystemWideAlertFormComponent', () => { expect(comp.back).not.toHaveBeenCalled(); }); + it('should not create the new alert when the enable button is clicked on an invalid the form', () => { + spyOn(comp as any, 'handleResponse'); + + comp.formMessage.patchValue(''); + comp.save(); + + expect((comp as any).handleResponse).not.toHaveBeenCalled(); + }); }); describe('back', () => { it('should navigate back to the home page', () => { diff --git a/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.ts b/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.ts index eaff12c169d..ea1c0a55f0c 100644 --- a/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.ts +++ b/src/app/system-wide-alert/alert-form/system-wide-alert-form.component.ts @@ -1,19 +1,55 @@ -import { Component, OnInit } from '@angular/core'; -import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; -import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { filter, map } from 'rxjs/operators'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { + NgbDatepickerModule, + NgbDateStruct, + NgbTimepickerModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + utcToZonedTime, + zonedTimeToUtc, +} from 'date-fns-tz'; +import { UiSwitchModule } from 'ngx-ui-switch'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; +import { + filter, + map, +} from 'rxjs/operators'; + import { PaginatedList } from '../../core/data/paginated-list.model'; -import { SystemWideAlert } from '../system-wide-alert.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; -import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; -import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; import { RemoteData } from '../../core/data/remote-data'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { Router } from '@angular/router'; import { RequestService } from '../../core/data/request.service'; -import { TranslateService } from '@ngx-translate/core'; +import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SystemWideAlert } from '../system-wide-alert.model'; /** @@ -22,7 +58,9 @@ import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'ds-system-wide-alert-form', styleUrls: ['./system-wide-alert-form.component.scss'], - templateUrl: './system-wide-alert-form.component.html' + templateUrl: './system-wide-alert-form.component.html', + standalone: true, + imports: [FormsModule, ReactiveFormsModule, UiSwitchModule, NgIf, NgbDatepickerModule, NgbTimepickerModule, AsyncPipe, TranslateModule, BtnDisabledDirective], }) export class SystemWideAlertFormComponent implements OnInit { @@ -82,7 +120,7 @@ export class SystemWideAlertFormComponent implements OnInit { protected notificationsService: NotificationsService, protected router: Router, protected requestService: RequestService, - protected translateService: TranslateService + protected translateService: TranslateService, ) { } @@ -98,12 +136,12 @@ export class SystemWideAlertFormComponent implements OnInit { }), map((payload: PaginatedList) => payload.page), filter((page) => isNotEmpty(page)), - map((page) => page[0]) + map((page) => page[0]), ); this.createForm(); const currentDate = new Date(); - this.minDate = {year: currentDate.getFullYear(), month: currentDate.getMonth() + 1, day: currentDate.getDate()}; + this.minDate = { year: currentDate.getFullYear(), month: currentDate.getMonth() + 1, day: currentDate.getDate() }; this.systemWideAlert$.subscribe((alert) => { @@ -117,11 +155,11 @@ export class SystemWideAlertFormComponent implements OnInit { */ createForm() { this.alertForm = new UntypedFormBuilder().group({ - formMessage: new UntypedFormControl('', { - validators: [Validators.required], - }), - formActive: new UntypedFormControl(false), - } + formMessage: new UntypedFormControl('', { + validators: [Validators.required], + }), + formActive: new UntypedFormControl(false), + }, ); this.setDateTime(new Date()); } @@ -168,8 +206,8 @@ export class SystemWideAlertFormComponent implements OnInit { private setDateTime(dateToSet) { - this.time = {hour: dateToSet.getHours(), minute: dateToSet.getMinutes()}; - this.date = {year: dateToSet.getFullYear(), month: dateToSet.getMonth() + 1, day: dateToSet.getDate()}; + this.time = { hour: dateToSet.getHours(), minute: dateToSet.getMinutes() }; + this.date = { year: dateToSet.getFullYear(), month: dateToSet.getMonth() + 1, day: dateToSet.getDate() }; this.updatePreviewTime(); } @@ -219,17 +257,19 @@ export class SystemWideAlertFormComponent implements OnInit { } else { alert.countdownTo = null; } - if (hasValue(this.currentAlert)) { - const updatedAlert = Object.assign(new SystemWideAlert(), this.currentAlert, alert); - this.handleResponse(this.systemWideAlertDataService.put(updatedAlert), 'system-wide-alert.form.update', navigateToHomePage); - } else { - this.handleResponse(this.systemWideAlertDataService.create(alert), 'system-wide-alert.form.create', navigateToHomePage); + if (this.alertForm.valid) { + if (hasValue(this.currentAlert)) { + const updatedAlert = Object.assign(new SystemWideAlert(), this.currentAlert, alert); + this.handleResponse(this.systemWideAlertDataService.put(updatedAlert), 'system-wide-alert.form.update', navigateToHomePage); + } else { + this.handleResponse(this.systemWideAlertDataService.create(alert), 'system-wide-alert.form.create', navigateToHomePage); + } } } private handleResponse(response$: Observable>, messagePrefix, navigateToHomePage: boolean) { response$.pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((response: RemoteData) => { if (response.hasSucceeded) { this.notificationsService.success(this.translateService.get(`${messagePrefix}.success`)); diff --git a/src/app/system-wide-alert/system-wide-alert-routes.ts b/src/app/system-wide-alert/system-wide-alert-routes.ts new file mode 100644 index 00000000000..a71007b6b3c --- /dev/null +++ b/src/app/system-wide-alert/system-wide-alert-routes.ts @@ -0,0 +1,13 @@ +import { Route } from '@angular/router'; + +import { siteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component'; + +export const ROUTES: Route[] = [ + { + path: '', + canActivate: [siteAdministratorGuard], + component: SystemWideAlertFormComponent, + }, + +]; diff --git a/src/app/system-wide-alert/system-wide-alert-routing.module.ts b/src/app/system-wide-alert/system-wide-alert-routing.module.ts deleted file mode 100644 index beb1b32187a..00000000000 --- a/src/app/system-wide-alert/system-wide-alert-routing.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { - SiteAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; -import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: '', - canActivate: [SiteAdministratorGuard], - component: SystemWideAlertFormComponent, - }, - - ]) - ] -}) -export class SystemWideAlertRoutingModule { - -} diff --git a/src/app/system-wide-alert/system-wide-alert.model.ts b/src/app/system-wide-alert/system-wide-alert.model.ts index 158deb26036..73bd7610a9d 100644 --- a/src/app/system-wide-alert/system-wide-alert.model.ts +++ b/src/app/system-wide-alert/system-wide-alert.model.ts @@ -1,4 +1,8 @@ -import { autoserialize, deserialize } from 'cerialize'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + import { typedObject } from '../core/cache/builders/build-decorators'; import { CacheableObject } from '../core/cache/cacheable-object.model'; import { HALLink } from '../core/shared/hal-link.model'; diff --git a/src/app/system-wide-alert/system-wide-alert.module.ts b/src/app/system-wide-alert/system-wide-alert.module.ts deleted file mode 100644 index ca200fa4f1c..00000000000 --- a/src/app/system-wide-alert/system-wide-alert.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { SystemWideAlertBannerComponent } from './alert-banner/system-wide-alert-banner.component'; -import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component'; -import { SharedModule } from '../shared/shared.module'; -import { SystemWideAlertDataService } from '../core/data/system-wide-alert-data.service'; -import { SystemWideAlertRoutingModule } from './system-wide-alert-routing.module'; -import { UiSwitchModule } from 'ngx-ui-switch'; -import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; - -@NgModule({ - imports: [ - FormsModule, - SharedModule, - UiSwitchModule, - SystemWideAlertRoutingModule, - NgbTimepickerModule, - NgbDatepickerModule, - ], - exports: [ - SystemWideAlertBannerComponent - ], - declarations: [ - SystemWideAlertBannerComponent, - SystemWideAlertFormComponent - ], - providers: [ - SystemWideAlertDataService - ] -}) -export class SystemWideAlertModule { - -} diff --git a/src/app/thumbnail/themed-thumbnail.component.ts b/src/app/thumbnail/themed-thumbnail.component.ts index 2a8d809104a..6d14378d3a5 100644 --- a/src/app/thumbnail/themed-thumbnail.component.ts +++ b/src/app/thumbnail/themed-thumbnail.component.ts @@ -1,13 +1,19 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { RemoteData } from '../core/data/remote-data'; +import { Bitstream } from '../core/shared/bitstream.model'; import { ThemedComponent } from '../shared/theme-support/themed.component'; -import { Component, Input } from '@angular/core'; import { ThumbnailComponent } from './thumbnail.component'; -import { Bitstream } from '../core/shared/bitstream.model'; -import { RemoteData } from '../core/data/remote-data'; @Component({ - selector: 'ds-themed-thumbnail', + selector: 'ds-thumbnail', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', + standalone: true, + imports: [ThumbnailComponent], }) export class ThemedThumbnailComponent extends ThemedComponent { diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index e69de29bb2d..c0f3ad91c0a 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -0,0 +1,19 @@ +
+
+
+
+ +
+
+
+ + +
+
+
+ {{ placeholder | translate }} +
+
+
+
diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index ebecb5e075b..38eceb84276 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -1,20 +1,38 @@ -import { DebugElement, Pipe, PipeTransform } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + DebugElement, + Pipe, + PipeTransform, + PLATFORM_ID, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Bitstream } from '../core/shared/bitstream.model'; -import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { ThumbnailComponent } from './thumbnail.component'; -import { RemoteData } from '../core/data/remote-data'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; import { AuthService } from '../core/auth/auth.service'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { RemoteData } from '../core/data/remote-data'; +import { Bitstream } from '../core/shared/bitstream.model'; import { FileService } from '../core/shared/file.service'; +import { getMockThemeService } from '../shared/mocks/theme-service.mock'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../shared/remote-data.utils'; +import { ThemeService } from '../shared/theme-support/theme.service'; +import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; import { VarDirective } from '../shared/utils/var.directive'; -import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { ThumbnailComponent } from './thumbnail.component'; -// eslint-disable-next-line @angular-eslint/pipe-prefix -@Pipe({ name: 'translate' }) +@Pipe({ + // eslint-disable-next-line @angular-eslint/pipe-prefix + name: 'translate', + standalone: true, +}) class MockTranslatePipe implements PipeTransform { transform(key: string): string { return 'TRANSLATED ' + key; @@ -31,286 +49,261 @@ describe('ThumbnailComponent', () => { let authService; let authorizationService; let fileService; + let spy; - beforeEach(waitForAsync(() => { - authService = jasmine.createSpyObj('AuthService', { - isAuthenticated: observableOf(true), - }); - authorizationService = jasmine.createSpyObj('AuthorizationService', { - isAuthorized: observableOf(true), - }); - fileService = jasmine.createSpyObj('FileService', { - retrieveFileDownloadLink: null - }); - fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`)); - - TestBed.configureTestingModule({ - declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe, VarDirective], - providers: [ - { provide: AuthService, useValue: authService }, - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: FileService, useValue: fileService } - ] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ThumbnailComponent); - fixture.detectChanges(); - - authService = TestBed.inject(AuthService); - - comp = fixture.componentInstance; // ThumbnailComponent test instance - de = fixture.debugElement.query(By.css('div.thumbnail')); - el = de.nativeElement; - }); + describe('when platform is browser', () => { + beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('AuthService', { + isAuthenticated: observableOf(true), + }); + authorizationService = jasmine.createSpyObj('AuthorizationService', { + isAuthorized: observableOf(true), + }); + fileService = jasmine.createSpyObj('FileService', { + retrieveFileDownloadLink: null, + }); + fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`)); + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ThumbnailComponent, + SafeUrlPipe, + MockTranslatePipe, + VarDirective, + ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: FileService, useValue: fileService }, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }).overrideComponent(ThumbnailComponent, { + add: { + imports: [MockTranslatePipe], + }, + }) + .compileComponents(); + })); - describe('loading', () => { - it('should start out with isLoading$ true', () => { - expect(comp.isLoading$.getValue()).toBeTrue(); - }); + beforeEach(() => { + fixture = TestBed.createComponent(ThumbnailComponent); + fixture.detectChanges(); - it('should set isLoading$ to false once an image is successfully loaded', () => { - comp.setSrc('http://bit.stream'); - fixture.debugElement.query(By.css('img.thumbnail-content')).triggerEventHandler('load', new Event('load')); - expect(comp.isLoading$.getValue()).toBeFalse(); - }); + authService = TestBed.inject(AuthService); - it('should set isLoading$ to false once the src is set to null', () => { - comp.setSrc(null); - expect(comp.isLoading$.getValue()).toBeFalse(); + comp = fixture.componentInstance; // ThumbnailComponent test instance + de = fixture.debugElement.query(By.css('div.thumbnail')); + el = de.nativeElement; }); - it('should show a loading animation while isLoading$ is true', () => { - expect(de.query(By.css('ds-themed-loading'))).toBeTruthy(); + describe('loading', () => { + it('should start out with isLoading$ true', () => { + expect(comp.isLoading$.getValue()).toBeTrue(); + }); - comp.isLoading$.next(false); - fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('ds-themed-loading'))).toBeFalsy(); - }); + it('should set isLoading$ to false once an image is successfully loaded', () => { + comp.setSrc('http://bit.stream'); + fixture.debugElement.query(By.css('img.thumbnail-content')).triggerEventHandler('load', new Event('load')); + expect(comp.isLoading$.getValue()).toBeFalse(); + }); - describe('with a thumbnail image', () => { - beforeEach(() => { - comp.src$.next('https://bit.stream'); - fixture.detectChanges(); + it('should set isLoading$ to false once the src is set to null', () => { + comp.setSrc(null); + expect(comp.isLoading$.getValue()).toBeFalse(); }); - it('should render but hide the image while loading and show it once done', () => { - let img = fixture.debugElement.query(By.css('img.thumbnail-content')); - expect(img).toBeTruthy(); - expect(img.classes['d-none']).toBeTrue(); + it('should show a loading animation while isLoading$ is true', () => { + expect(de.query(By.css('ds-loading'))).toBeTruthy(); comp.isLoading$.next(false); fixture.detectChanges(); - img = fixture.debugElement.query(By.css('img.thumbnail-content')); - expect(img).toBeTruthy(); - expect(img.classes['d-none']).toBeFalsy(); + expect(fixture.debugElement.query(By.css('ds-loading'))).toBeFalsy(); }); - }); + describe('with a thumbnail image', () => { + beforeEach(() => { + comp.src$.next('https://bit.stream'); + fixture.detectChanges(); + }); - describe('without a thumbnail image', () => { - beforeEach(() => { - comp.src$.next(null); - fixture.detectChanges(); - }); + it('should render but hide the image while loading and show it once done', () => { + let img = fixture.debugElement.query(By.css('img.thumbnail-content')); + expect(img).toBeTruthy(); + expect(img.classes['d-none']).toBeTrue(); - it('should only show the HTML placeholder once done loading', () => { - expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeFalsy(); + comp.isLoading$.next(false); + fixture.detectChanges(); + img = fixture.debugElement.query(By.css('img.thumbnail-content')); + expect(img).toBeTruthy(); + expect(img.classes['d-none']).toBeFalsy(); + }); - comp.isLoading$.next(false); - fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeTruthy(); }); - }); - }); + describe('without a thumbnail image', () => { + beforeEach(() => { + comp.src$.next(null); + fixture.detectChanges(); + }); - const errorHandler = () => { - let setSrcSpy; + it('should only show the HTML placeholder once done loading', () => { + expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeFalsy(); - beforeEach(() => { - // disconnect error handler to be sure it's only called once - const img = fixture.debugElement.query(By.css('img.thumbnail-content')); - img.nativeNode.onerror = null; + comp.isLoading$.next(false); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeTruthy(); + }); + }); - comp.ngOnChanges({}); - setSrcSpy = spyOn(comp, 'setSrc').and.callThrough(); }); - describe('retry with authentication token', () => { - it('should remember that it already retried once', () => { - expect(comp.retriedWithToken).toBeFalse(); - comp.errorHandler(); - expect(comp.retriedWithToken).toBeTrue(); - }); + const errorHandler = () => { + let setSrcSpy; - describe('if not logged in', () => { - beforeEach(() => { - authService.isAuthenticated.and.returnValue(observableOf(false)); - }); + beforeEach(() => { + // disconnect error handler to be sure it's only called once + const img = fixture.debugElement.query(By.css('img.thumbnail-content')); + img.nativeNode.onerror = null; - it('should fall back to default', () => { - comp.errorHandler(); - expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); - }); + comp.ngOnChanges({}); + setSrcSpy = spyOn(comp, 'setSrc').and.callThrough(); }); - describe('if logged in', () => { - beforeEach(() => { - authService.isAuthenticated.and.returnValue(observableOf(true)); + describe('retry with authentication token', () => { + it('should remember that it already retried once', () => { + expect(comp.retriedWithToken).toBeFalse(); + comp.errorHandler(); + expect(comp.retriedWithToken).toBeTrue(); }); - describe('and authorized to download the thumbnail', () => { + describe('if not logged in', () => { beforeEach(() => { - authorizationService.isAuthorized.and.returnValue(observableOf(true)); + authService.isAuthenticated.and.returnValue(observableOf(false)); }); - it('should add an authentication token to the thumbnail URL', () => { + it('should fall back to default', () => { comp.errorHandler(); - - if ((comp.thumbnail as RemoteData)?.hasFailed) { - // If we failed to retrieve the Bitstream in the first place, fall back to the default - expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); - } else { - expect(setSrcSpy).toHaveBeenCalledWith(CONTENT + '?authentication-token=fake'); - } + expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); }); }); - describe('but not authorized to download the thumbnail', () => { + describe('if logged in', () => { beforeEach(() => { - authorizationService.isAuthorized.and.returnValue(observableOf(false)); + authService.isAuthenticated.and.returnValue(observableOf(true)); }); - it('should fall back to default', () => { - comp.errorHandler(); - - expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); - - // We don't need to check authorization if we failed to retrieve the Bitstreamin the first place - if (!(comp.thumbnail as RemoteData)?.hasFailed) { - expect(authorizationService.isAuthorized).toHaveBeenCalled(); - } + describe('and authorized to download the thumbnail', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(true)); + }); + + it('should add an authentication token to the thumbnail URL', () => { + comp.errorHandler(); + + if ((comp.thumbnail as RemoteData)?.hasFailed) { + // If we failed to retrieve the Bitstream in the first place, fall back to the default + expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); + } else { + expect(setSrcSpy).toHaveBeenCalledWith(CONTENT + '?authentication-token=fake'); + } + }); }); - }); - }); - }); - describe('after retrying with token', () => { - beforeEach(() => { - comp.retriedWithToken = true; - }); + describe('but not authorized to download the thumbnail', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(false)); + }); - it('should fall back to default', () => { - comp.errorHandler(); - expect(authService.isAuthenticated).not.toHaveBeenCalled(); - expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled(); - expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); - }); - }); - }; - - describe('fallback', () => { - describe('if there is a default image', () => { - it('should display the default image', () => { - comp.src$.next('http://bit.stream'); - comp.defaultImage = 'http://default.img'; - comp.errorHandler(); - expect(comp.src$.getValue()).toBe(comp.defaultImage); - }); + it('should fall back to default', () => { + comp.errorHandler(); - it('should include the alt text', () => { - comp.src$.next('http://bit.stream'); - comp.defaultImage = 'http://default.img'; - comp.errorHandler(); + expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); - fixture.detectChanges(); - const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; - expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + // We don't need to check authorization if we failed to retrieve the Bitstreamin the first place + if (!(comp.thumbnail as RemoteData)?.hasFailed) { + expect(authorizationService.isAuthorized).toHaveBeenCalled(); + } + }); + }); + }); }); - }); - describe('if there is no default image', () => { - it('should display the HTML placeholder', () => { - comp.src$.next('http://default.img'); - comp.defaultImage = null; - comp.errorHandler(); - expect(comp.src$.getValue()).toBe(null); + describe('after retrying with token', () => { + beforeEach(() => { + comp.retriedWithToken = true; + }); - fixture.detectChanges(); - const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement; - expect(placeholder.innerHTML).toContain('TRANSLATED ' + comp.placeholder); + it('should fall back to default', () => { + comp.errorHandler(); + expect(authService.isAuthenticated).not.toHaveBeenCalled(); + expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled(); + expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage); + }); }); - }); - }); + }; - describe('with thumbnail as Bitstream', () => { - let thumbnail; - beforeEach(() => { - thumbnail = new Bitstream(); - thumbnail._links = { - self: { href: 'self.url' }, - bundle: { href: 'bundle.url' }, - format: { href: 'format.url' }, - content: { href: CONTENT }, - thumbnail: undefined, - }; - comp.thumbnail = thumbnail; - }); + describe('fallback', () => { + describe('if there is a default image', () => { + it('should display the default image', () => { + comp.src$.next('http://bit.stream'); + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); + expect(comp.src$.getValue()).toBe(comp.defaultImage); + }); - describe('if content can be loaded', () => { - it('should display an image', () => { - comp.ngOnChanges({}); - fixture.detectChanges(); - const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(thumbnail._links.content.href); - }); + it('should include the alt text', () => { + comp.src$.next('http://bit.stream'); + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); - it('should include the alt text', () => { - comp.ngOnChanges({}); - fixture.detectChanges(); - const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; - expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + fixture.detectChanges(); + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; + expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + }); }); - }); - describe('if content can\'t be loaded', () => { - errorHandler(); - }); - }); - - describe('with thumbnail as RemoteData', () => { - let thumbnail: Bitstream; + describe('if there is no default image', () => { + it('should display the HTML placeholder', () => { + comp.src$.next('http://default.img'); + comp.defaultImage = null; + comp.errorHandler(); + expect(comp.src$.getValue()).toBe(null); - beforeEach(() => { - thumbnail = new Bitstream(); - thumbnail._links = { - self: { href: 'self.url' }, - bundle: { href: 'bundle.url' }, - format: { href: 'format.url' }, - content: { href: CONTENT }, - thumbnail: undefined - }; + fixture.detectChanges(); + const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement; + expect(placeholder.innerHTML).toContain('TRANSLATED ' + comp.placeholder); + }); + }); }); - describe('if RemoteData succeeded', () => { + describe('with thumbnail as Bitstream', () => { + let thumbnail; beforeEach(() => { - comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail); + thumbnail = new Bitstream(); + thumbnail._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: CONTENT }, + thumbnail: undefined, + }; + comp.thumbnail = thumbnail; }); describe('if content can be loaded', () => { it('should display an image', () => { comp.ngOnChanges({}); fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; expect(image.getAttribute('src')).toBe(thumbnail._links.content.href); }); - it('should display the alt text', () => { + it('should include the alt text', () => { comp.ngOnChanges({}); fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); }); }); @@ -320,16 +313,117 @@ describe('ThumbnailComponent', () => { }); }); - describe('if RemoteData failed', () => { + describe('with thumbnail as RemoteData', () => { + let thumbnail: Bitstream; + beforeEach(() => { - comp.thumbnail = createFailedRemoteDataObject(); + thumbnail = new Bitstream(); + thumbnail._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: CONTENT }, + thumbnail: undefined, + }; }); - it('should show the default image', () => { - comp.defaultImage = 'default/image.jpg'; - comp.ngOnChanges({}); - expect(comp.src$.getValue()).toBe('default/image.jpg'); + describe('if RemoteData succeeded', () => { + beforeEach(() => { + comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail); + }); + + describe('if content can be loaded', () => { + it('should display an image', () => { + comp.ngOnChanges({}); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('src')).toBe(thumbnail._links.content.href); + }); + + it('should display the alt text', () => { + comp.ngOnChanges({}); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + }); + }); + + describe('if content can\'t be loaded', () => { + errorHandler(); + }); + }); + + describe('if RemoteData failed', () => { + beforeEach(() => { + comp.thumbnail = createFailedRemoteDataObject(); + }); + + it('should show the default image', () => { + comp.defaultImage = 'default/image.jpg'; + comp.ngOnChanges({}); + expect(comp.src$.getValue()).toBe('default/image.jpg'); + }); }); }); }); + + describe('when platform is server', () => { + beforeEach(waitForAsync(() => { + + authService = jasmine.createSpyObj('AuthService', { + isAuthenticated: observableOf(true), + }); + authorizationService = jasmine.createSpyObj('AuthorizationService', { + isAuthorized: observableOf(true), + }); + fileService = jasmine.createSpyObj('FileService', { + retrieveFileDownloadLink: null, + }); + fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`)); + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ThumbnailComponent, + SafeUrlPipe, + MockTranslatePipe, + VarDirective, + ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: FileService, useValue: fileService }, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }).overrideComponent(ThumbnailComponent, { + add: { + imports: [MockTranslatePipe], + }, + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ThumbnailComponent); + spyOn(fixture.componentInstance, 'setSrc').and.callThrough(); + fixture.detectChanges(); + + authService = TestBed.inject(AuthService); + + comp = fixture.componentInstance; // ThumbnailComponent test instance + de = fixture.debugElement.query(By.css('div.thumbnail')); + el = de.nativeElement; + }); + + it('should start out with isLoading$ true', () => { + expect(comp.isLoading$.getValue()).toBeTrue(); + expect(de.query(By.css('ds-loading'))).toBeTruthy(); + }); + + it('should not call setSrc', () => { + expect(comp.setSrc).not.toHaveBeenCalled(); + }); + + }); }); diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts index 7f6531cc5e3..179af1dac0a 100644 --- a/src/app/thumbnail/thumbnail.component.ts +++ b/src/app/thumbnail/thumbnail.component.ts @@ -1,13 +1,35 @@ -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { Bitstream } from '../core/shared/bitstream.model'; -import { hasNoValue, hasValue } from '../shared/empty.util'; -import { RemoteData } from '../core/data/remote-data'; -import { BehaviorSubject, of as observableOf } from 'rxjs'; +import { + CommonModule, + isPlatformBrowser, +} from '@angular/common'; +import { + Component, + Inject, + Input, + OnChanges, + PLATFORM_ID, + SimpleChanges, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + of as observableOf, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { FeatureID } from '../core/data/feature-authorization/feature-id'; -import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; + 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 { RemoteData } from '../core/data/remote-data'; +import { Bitstream } from '../core/shared/bitstream.model'; import { FileService } from '../core/shared/file.service'; +import { + hasNoValue, + hasValue, +} from '../shared/empty.util'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; +import { VarDirective } from '../shared/utils/var.directive'; /** * This component renders a given Bitstream as a thumbnail. @@ -15,9 +37,11 @@ import { FileService } from '../core/shared/file.service'; * If no Bitstream is provided, an HTML placeholder will be rendered instead. */ @Component({ - selector: 'ds-thumbnail', + selector: 'ds-base-thumbnail', styleUrls: ['./thumbnail.component.scss'], templateUrl: './thumbnail.component.html', + standalone: true, + imports: [VarDirective, CommonModule, ThemedLoadingComponent, TranslateModule, SafeUrlPipe], }) export class ThumbnailComponent implements OnChanges { /** @@ -34,7 +58,7 @@ export class ThumbnailComponent implements OnChanges { /** * The src attribute used in the template to render the image. */ - src$ = new BehaviorSubject(undefined); + src$: BehaviorSubject = new BehaviorSubject(undefined); retriedWithToken = false; @@ -57,9 +81,10 @@ export class ThumbnailComponent implements OnChanges { * Whether the thumbnail is currently loading * Start out as true to avoid flashing the alt text while a thumbnail is being loaded. */ - isLoading$ = new BehaviorSubject(true); + isLoading$: BehaviorSubject = new BehaviorSubject(true); constructor( + @Inject(PLATFORM_ID) private platformID: any, protected auth: AuthService, protected authorizationService: AuthorizationDataService, protected fileService: FileService, @@ -71,16 +96,18 @@ export class ThumbnailComponent implements OnChanges { * Use a default image if no actual image is available. */ ngOnChanges(changes: SimpleChanges): void { - if (hasNoValue(this.thumbnail)) { - this.setSrc(this.defaultImage); - return; - } + if (isPlatformBrowser(this.platformID)) { + if (hasNoValue(this.thumbnail)) { + this.setSrc(this.defaultImage); + return; + } - const src = this.contentHref; - if (hasValue(src)) { - this.setSrc(src); - } else { - this.setSrc(this.defaultImage); + const src = this.contentHref; + if (hasValue(src)) { + this.setSrc(src); + } else { + this.setSrc(this.defaultImage); + } } } @@ -134,7 +161,7 @@ export class ThumbnailComponent implements OnChanges { } else { return observableOf(null); } - }) + }), ).subscribe((url: string) => { if (hasValue(url)) { // If we got a URL, try to load it @@ -162,9 +189,22 @@ export class ThumbnailComponent implements OnChanges { * @param src */ setSrc(src: string): void { - this.src$.next(src); - if (src === null) { - this.isLoading$.next(false); + // only update the src if it has changed (the parent component may fire the same one multiple times + if (this.src$.getValue() !== src) { + // every time the src changes we need to start the loading animation again, as it's possible + // that it is first set to null when the parent component initializes and then set to + // the actual value + // + // isLoading$ will be set to false by the error or success handler afterwards, except in the + // case where src is null, then we have to set it manually here (because those handlers won't + // trigger) + if (src !== null && this.isLoading$.getValue() === false) { + this.isLoading$.next(true); + } + this.src$.next(src); + if (src === null && this.isLoading$.getValue() === true) { + this.isLoading$.next(false); + } } } diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.spec.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.spec.ts index cbb85b6ad87..d370c83a93e 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.spec.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.spec.ts @@ -1,8 +1,13 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AdvancedWorkflowActionPageComponent } from './advanced-workflow-action-page.component'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; +import { AdvancedWorkflowActionsLoaderComponent } from '../advanced-workflow-actions-loader/advanced-workflow-actions-loader.component'; +import { AdvancedWorkflowActionPageComponent } from './advanced-workflow-action-page.component'; + describe('AdvancedWorkflowActionPageComponent', () => { let component: AdvancedWorkflowActionPageComponent; let fixture: ComponentFixture; @@ -11,8 +16,6 @@ describe('AdvancedWorkflowActionPageComponent', () => { await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - ], - declarations: [ AdvancedWorkflowActionPageComponent, ], providers: [ @@ -27,7 +30,10 @@ describe('AdvancedWorkflowActionPageComponent', () => { }, }, ], - }).compileComponents(); + }).overrideComponent(AdvancedWorkflowActionPageComponent, { + remove: { imports: [AdvancedWorkflowActionsLoaderComponent] }, + }) + .compileComponents(); }); beforeEach(() => { diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.ts index 91dce19a5ea..91796e826fe 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component.ts @@ -1,5 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AdvancedWorkflowActionsLoaderComponent } from '../advanced-workflow-actions-loader/advanced-workflow-actions-loader.component'; /** * The Advanced Workflow page containing the correct {@link AdvancedWorkflowActionComponent} @@ -8,7 +14,12 @@ import { ActivatedRoute } from '@angular/router'; @Component({ selector: 'ds-advanced-workflow-action-page', templateUrl: './advanced-workflow-action-page.component.html', - styleUrls: ['./advanced-workflow-action-page.component.scss'] + styleUrls: ['./advanced-workflow-action-page.component.scss'], + imports: [ + AdvancedWorkflowActionsLoaderComponent, + TranslateModule, + ], + standalone: true, }) export class AdvancedWorkflowActionPageComponent implements OnInit { diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.spec.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.spec.ts index d6d6f973c44..fe450237185 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.spec.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.spec.ts @@ -1,35 +1,48 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { - AdvancedWorkflowActionRatingComponent, - ADVANCED_WORKFLOW_TASK_OPTION_RATING -} from './advanced-workflow-action-rating.component'; -import { ActivatedRoute, Router } from '@angular/router'; + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { RouteService } from '../../../core/services/route.service'; -import { routeServiceStub } from '../../../shared/testing/route-service.stub'; + +import { RequestService } from '../../../core/data/request.service'; import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; +import { RouteService } from '../../../core/services/route.service'; +import { Item } from '../../../core/shared/item.model'; +import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; +import { RatingAdvancedWorkflowInfo } from '../../../core/tasks/models/rating-advanced-workflow-info.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; import { ClaimedTaskDataServiceStub } from '../../../shared/testing/claimed-task-data-service.stub'; +import { LocationStub } from '../../../shared/testing/location.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { RequestServiceStub } from '../../../shared/testing/request-service.stub'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; +import { RouterStub } from '../../../shared/testing/router.stub'; import { WorkflowActionDataServiceStub } from '../../../shared/testing/workflow-action-data-service.stub'; import { WorkflowItemDataServiceStub } from '../../../shared/testing/workflow-item-data-service.stub'; -import { RouterStub } from '../../../shared/testing/router.stub'; -import { TranslateModule } from '@ngx-translate/core'; import { VarDirective } from '../../../shared/utils/var.directive'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; -import { createSuccessfulRemoteDataObject$, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { Item } from '../../../core/shared/item.model'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; -import { RatingAdvancedWorkflowInfo } from '../../../core/tasks/models/rating-advanced-workflow-info.model'; -import { RequestService } from '../../../core/data/request.service'; -import { RequestServiceStub } from '../../../shared/testing/request-service.stub'; -import { LocationStub } from '../../../shared/testing/location.stub'; +import { + ADVANCED_WORKFLOW_TASK_OPTION_RATING, + AdvancedWorkflowActionRatingComponent, +} from './advanced-workflow-action-rating.component'; const claimedTaskId = '2'; const workflowId = '1'; @@ -57,8 +70,6 @@ describe('AdvancedWorkflowActionRatingComponent', () => { NgbModule, ReactiveFormsModule, TranslateModule.forRoot(), - ], - declarations: [ AdvancedWorkflowActionRatingComponent, VarDirective, ], diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.ts index ebf1ead64ab..b8620e7d897 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component.ts @@ -1,11 +1,26 @@ -import { Component, OnInit } from '@angular/core'; import { - rendersAdvancedWorkflowTaskOption -} from '../../../shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator'; -import { AdvancedWorkflowActionComponent } from '../advanced-workflow-action/advanced-workflow-action.component'; -import { UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; -import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; + AsyncPipe, + NgClass, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + import { RatingAdvancedWorkflowInfo } from '../../../core/tasks/models/rating-advanced-workflow-info.model'; +import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; +import { ModifyItemOverviewComponent } from '../../../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { AdvancedWorkflowActionComponent } from '../advanced-workflow-action/advanced-workflow-action.component'; export const ADVANCED_WORKFLOW_TASK_OPTION_RATING = 'submit_score'; export const ADVANCED_WORKFLOW_ACTION_RATING = 'scorereviewaction'; @@ -13,12 +28,22 @@ export const ADVANCED_WORKFLOW_ACTION_RATING = 'scorereviewaction'; /** * The page on which reviewers can rate submitted items. */ -@rendersAdvancedWorkflowTaskOption(ADVANCED_WORKFLOW_ACTION_RATING) @Component({ selector: 'ds-advanced-workflow-action-rating-reviewer', templateUrl: './advanced-workflow-action-rating.component.html', styleUrls: ['./advanced-workflow-action-rating.component.scss'], preserveWhitespaces: false, + imports: [ + ModifyItemOverviewComponent, + NgIf, + AsyncPipe, + TranslateModule, + NgbRatingModule, + NgClass, + ReactiveFormsModule, + VarDirective, + ], + standalone: true, }) export class AdvancedWorkflowActionRatingComponent extends AdvancedWorkflowActionComponent implements OnInit { diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.spec.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.spec.ts index 71952ce9c70..eb79aa390cc 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.spec.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.spec.ts @@ -1,32 +1,45 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { - AdvancedWorkflowActionSelectReviewerComponent, - ADVANCED_WORKFLOW_TASK_OPTION_SELECT_REVIEWER, -} from './advanced-workflow-action-select-reviewer.component'; -import { ActivatedRoute, Router } from '@angular/router'; -import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; -import { WorkflowItemDataServiceStub } from '../../../shared/testing/workflow-item-data-service.stub'; -import { WorkflowActionDataServiceStub } from '../../../shared/testing/workflow-action-data-service.stub'; -import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; -import { RouteService } from '../../../core/services/route.service'; -import { routeServiceStub } from '../../../shared/testing/route-service.stub'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; -import { ClaimedTaskDataServiceStub } from '../../../shared/testing/claimed-task-data-service.stub'; import { of as observableOf } from 'rxjs'; -import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; -import { createSuccessfulRemoteDataObject$, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; + +import { RequestService } from '../../../core/data/request.service'; +import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; +import { RouteService } from '../../../core/services/route.service'; import { Item } from '../../../core/shared/item.model'; -import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock'; +import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RequestService } from '../../../core/data/request.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { ClaimedTaskDataServiceStub } from '../../../shared/testing/claimed-task-data-service.stub'; +import { + EPersonMock, + EPersonMock2, +} from '../../../shared/testing/eperson.mock'; +import { LocationStub } from '../../../shared/testing/location.stub'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { RequestServiceStub } from '../../../shared/testing/request-service.stub'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; import { RouterStub } from '../../../shared/testing/router.stub'; -import { LocationStub } from '../../../shared/testing/location.stub'; +import { WorkflowActionDataServiceStub } from '../../../shared/testing/workflow-action-data-service.stub'; +import { WorkflowItemDataServiceStub } from '../../../shared/testing/workflow-item-data-service.stub'; +import { + ADVANCED_WORKFLOW_TASK_OPTION_SELECT_REVIEWER, + AdvancedWorkflowActionSelectReviewerComponent, +} from './advanced-workflow-action-select-reviewer.component'; const claimedTaskId = '2'; const workflowId = '1'; @@ -55,8 +68,6 @@ describe('AdvancedWorkflowActionSelectReviewerComponent', () => { await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - ], - declarations: [ AdvancedWorkflowActionSelectReviewerComponent, ], providers: [ diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.ts index 329af733510..72f5623626c 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component.ts @@ -1,27 +1,37 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { Location } from '@angular/common'; import { - rendersAdvancedWorkflowTaskOption -} from '../../../shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator'; -import { AdvancedWorkflowActionComponent } from '../advanced-workflow-action/advanced-workflow-action.component'; -import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; + CommonModule, + Location, +} from '@angular/common'; import { - SelectReviewerAdvancedWorkflowInfo -} from '../../../core/tasks/models/select-reviewer-advanced-workflow-info.model'; + Component, + OnDestroy, + OnInit, +} from '@angular/core'; import { - EPersonListActionConfig -} from '../../../access-control/group-registry/group-form/members-list/members-list.component'; + ActivatedRoute, + Params, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; import { Subscription } from 'rxjs'; + +import { EPersonListActionConfig } from '../../../access-control/group-registry/group-form/members-list/members-list.component'; +import { RequestService } from '../../../core/data/request.service'; +import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; -import { ActivatedRoute, Params, Router } from '@angular/router'; -import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { RouteService } from '../../../core/services/route.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; -import { RequestService } from '../../../core/data/request.service'; +import { SelectReviewerAdvancedWorkflowInfo } from '../../../core/tasks/models/select-reviewer-advanced-workflow-info.model'; +import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; +import { ModifyItemOverviewComponent } from '../../../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { AdvancedWorkflowActionComponent } from '../advanced-workflow-action/advanced-workflow-action.component'; +import { ReviewersListComponent } from './reviewers-list/reviewers-list.component'; export const ADVANCED_WORKFLOW_TASK_OPTION_SELECT_REVIEWER = 'submit_select_reviewer'; export const ADVANCED_WORKFLOW_ACTION_SELECT_REVIEWER = 'selectrevieweraction'; @@ -29,11 +39,17 @@ export const ADVANCED_WORKFLOW_ACTION_SELECT_REVIEWER = 'selectrevieweraction'; /** * The page on which Review Managers can assign Reviewers to review an item. */ -@rendersAdvancedWorkflowTaskOption(ADVANCED_WORKFLOW_ACTION_SELECT_REVIEWER) @Component({ selector: 'ds-advanced-workflow-action-select-reviewer', templateUrl: './advanced-workflow-action-select-reviewer.component.html', styleUrls: ['./advanced-workflow-action-select-reviewer.component.scss'], + imports: [ + CommonModule, + ModifyItemOverviewComponent, + TranslateModule, + ReviewersListComponent, + ], + standalone: true, }) export class AdvancedWorkflowActionSelectReviewerComponent extends AdvancedWorkflowActionComponent implements OnInit, OnDestroy { @@ -84,7 +100,7 @@ export class AdvancedWorkflowActionSelectReviewerComponent extends AdvancedWorkf remove: { css: 'btn-outline-danger', disabled: false, - icon: 'fas fa-minus' + icon: 'fas fa-minus', }, }; } else { @@ -97,7 +113,7 @@ export class AdvancedWorkflowActionSelectReviewerComponent extends AdvancedWorkf remove: { css: 'btn-primary', disabled: true, - icon: 'fas fa-check' + icon: 'fas fa-check', }, }; } diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.spec.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.spec.ts index 7c8db782ce6..e826de1c0e4 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.spec.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.spec.ts @@ -1,38 +1,78 @@ import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA, SimpleChange, DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, TestBed, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { Router } from '@angular/router'; +import { + DebugElement, + NO_ERRORS_SCHEMA, + SimpleChange, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Observable, of as observableOf } from 'rxjs'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { RestResponse } from '../../../../core/cache/response.models'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { EPerson } from '../../../../core/eperson/models/eperson.model'; import { Group } from '../../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; +import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../../../shared/mocks/router.mock'; +import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; -import { ReviewersListComponent } from './reviewers-list.component'; -import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { + createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$, - createNoContentRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; -import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; -import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub'; +import { + EPersonMock, + EPersonMock2, +} from '../../../../shared/testing/eperson.mock'; +import { GroupMock } from '../../../../shared/testing/group-mock'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { RouterMock } from '../../../../shared/mocks/router.mock'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; -import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { ReviewersListComponent } from './reviewers-list.component'; +// todo: optimize imports + +// NOTE: Because ReviewersListComponent extends MembersListComponent, the below tests ONLY validate +// features which are *unique* to ReviewersListComponent. All other features are tested in the +// members-list.component.spec.ts file. describe('ReviewersListComponent', () => { let component: ReviewersListComponent; let fixture: ComponentFixture; @@ -40,31 +80,27 @@ describe('ReviewersListComponent', () => { let builderService: FormBuilderService; let ePersonDataServiceStub: any; let groupsDataServiceStub: any; - let activeGroup; - let allEPersons; - let allGroups; - let epersonMembers; - let subgroupMembers; + let activeGroup: Group; + let epersonMembers: EPerson[]; + let epersonNonMembers: EPerson[]; let paginationService; - let ePersonDtoModel1: EpersonDtoModel; - let ePersonDtoModel2: EpersonDtoModel; beforeEach(waitForAsync(() => { activeGroup = GroupMock; epersonMembers = [EPersonMock2]; - subgroupMembers = [GroupMock2]; - allEPersons = [EPersonMock, EPersonMock2]; - allGroups = [GroupMock, GroupMock2]; + epersonNonMembers = [EPersonMock]; ePersonDataServiceStub = { activeGroup: activeGroup, epersonMembers: epersonMembers, - subgroupMembers: subgroupMembers, + epersonNonMembers: epersonNonMembers, + // This method is used to get all the current members findListByHref(_href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, + // This method is used to search across *non-members* searchByScope(scope: string, query: string): Observable>> { if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allEPersons)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), epersonNonMembers)); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); }, @@ -74,29 +110,26 @@ describe('ReviewersListComponent', () => { clearLinkRequests() { // empty }, - getEPeoplePageRouterLink(): string { - return '/access-control/epeople'; - } }; groupsDataServiceStub = { activeGroup: activeGroup, epersonMembers: epersonMembers, - subgroupMembers: subgroupMembers, - allGroups: allGroups, + epersonNonMembers: epersonNonMembers, getActiveGroup(): Observable { return observableOf(activeGroup); }, getEPersonMembers() { return this.epersonMembers; }, - searchGroups(query: string): Observable>> { - if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.allGroups)); - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); - }, - addMemberToGroup(parentGroup, eperson: EPerson): Observable { - this.epersonMembers = [...this.epersonMembers, eperson]; + addMemberToGroup(parentGroup, epersonToAdd: EPerson): Observable { + // Add eperson to list of members + this.epersonMembers = [...this.epersonMembers, epersonToAdd]; + // Remove eperson from list of non-members + this.epersonNonMembers.forEach( (eperson: EPerson, index: number) => { + if (eperson.id === epersonToAdd.id) { + this.epersonNonMembers.splice(index, 1); + } + }); return observableOf(new RestResponse(true, 200, 'Success')); }, clearGroupsRequests() { @@ -109,42 +142,39 @@ describe('ReviewersListComponent', () => { return '/access-control/groups/' + group.id; }, deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable { - this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => { - if (eperson.id !== epersonToDelete.id) { - return eperson; + // Remove eperson from list of members + this.epersonMembers.forEach( (eperson: EPerson, index: number) => { + if (eperson.id === epersonToDelete.id) { + this.epersonMembers.splice(index, 1); } }); - if (this.epersonMembers === undefined) { - this.epersonMembers = []; - } + // Add eperson to list of non-members + this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete]; return observableOf(new RestResponse(true, 200, 'Success')); }, + // Used to find the currently active group findById(id: string) { - for (const group of allGroups) { - if (group.id === id) { - return createSuccessfulRemoteDataObject$(group); - } + if (activeGroup.id === id) { + return createSuccessfulRemoteDataObject$(activeGroup); } return createNoContentRemoteDataObject$(); }, editGroup() { // empty - } + }, }; builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); - TestBed.configureTestingModule({ + return TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), - ], - declarations: [ReviewersListComponent], + useClass: TranslateLoaderMock, + }, + }), ReviewersListComponent], providers: [ReviewersListComponent, { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: GroupDataService, useValue: groupsDataServiceStub }, @@ -152,9 +182,16 @@ describe('ReviewersListComponent', () => { { provide: FormBuilderService, useValue: builderService }, { provide: Router, useValue: new RouterMock() }, { provide: PaginationService, useValue: paginationService }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(ReviewersListComponent, { + remove: { + imports: [ContextHelpDirective, PaginationComponent], + }, + }) + .compileComponents(); })); beforeEach(() => { @@ -169,17 +206,11 @@ describe('ReviewersListComponent', () => { fixture.debugElement.nativeElement.remove(); })); - beforeEach(() => { - ePersonDtoModel1 = new EpersonDtoModel(); - ePersonDtoModel1.eperson = EPersonMock; - ePersonDtoModel2 = new EpersonDtoModel(); - ePersonDtoModel2.eperson = EPersonMock2; - }); describe('when no group is selected', () => { beforeEach(() => { component.ngOnChanges({ - groupId: new SimpleChange(undefined, null, true) + groupId: new SimpleChange(undefined, null, true), }); fixture.detectChanges(); }); @@ -193,12 +224,44 @@ describe('ReviewersListComponent', () => { })).not.toBeTruthy(); }); }); + + it('should replace the value when a new member is added when multipleReviewers is false', () => { + spyOn(component.selectedReviewersUpdated, 'emit'); + component.multipleReviewers = false; + component.selectedReviewers = [EPersonMock]; + + component.addMemberToGroup(EPersonMock2); + + expect(component.selectedReviewers).toEqual([EPersonMock2]); + expect(component.selectedReviewersUpdated.emit).toHaveBeenCalledWith([EPersonMock2]); + }); + + it('should add the value when a new member is added when multipleReviewers is true', () => { + spyOn(component.selectedReviewersUpdated, 'emit'); + component.multipleReviewers = true; + component.selectedReviewers = [EPersonMock]; + + component.addMemberToGroup(EPersonMock2); + + expect(component.selectedReviewers).toEqual([EPersonMock, EPersonMock2]); + expect(component.selectedReviewersUpdated.emit).toHaveBeenCalledWith([EPersonMock, EPersonMock2]); + }); + + it('should delete the member when present', () => { + spyOn(component.selectedReviewersUpdated, 'emit'); + component.selectedReviewers = [EPersonMock]; + + component.deleteMemberFromGroup(EPersonMock); + + expect(component.selectedReviewers).toEqual([]); + expect(component.selectedReviewersUpdated.emit).toHaveBeenCalledWith([]); + }); }); describe('when a group is selected', () => { beforeEach(() => { component.ngOnChanges({ - groupId: new SimpleChange(undefined, GroupMock.id, true) + groupId: new SimpleChange(undefined, GroupMock.id, true), }); fixture.detectChanges(); }); @@ -214,39 +277,4 @@ describe('ReviewersListComponent', () => { }); }); - - it('should replace the value when a new member is added when multipleReviewers is false', () => { - spyOn(component.selectedReviewersUpdated, 'emit'); - component.multipleReviewers = false; - component.selectedReviewers = [ePersonDtoModel1]; - - component.addMemberToGroup(ePersonDtoModel2); - - expect(component.selectedReviewers).toEqual([ePersonDtoModel2]); - expect(component.selectedReviewersUpdated.emit).toHaveBeenCalledWith([ePersonDtoModel2.eperson]); - }); - - it('should add the value when a new member is added when multipleReviewers is true', () => { - spyOn(component.selectedReviewersUpdated, 'emit'); - component.multipleReviewers = true; - component.selectedReviewers = [ePersonDtoModel1]; - - component.addMemberToGroup(ePersonDtoModel2); - - expect(component.selectedReviewers).toEqual([ePersonDtoModel1, ePersonDtoModel2]); - expect(component.selectedReviewersUpdated.emit).toHaveBeenCalledWith([ePersonDtoModel1.eperson, ePersonDtoModel2.eperson]); - }); - - it('should delete the member when present', () => { - spyOn(component.selectedReviewersUpdated, 'emit'); - ePersonDtoModel1.memberOfGroup = true; - component.selectedReviewers = [ePersonDtoModel1]; - - component.deleteMemberFromGroup(ePersonDtoModel1); - - expect(component.selectedReviewers).toEqual([]); - expect(ePersonDtoModel1.memberOfGroup).toBeFalse(); - expect(component.selectedReviewersUpdated.emit).toHaveBeenCalledWith([]); - }); - }); diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts index 6984a1d86dd..c43fabdb83a 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts @@ -1,31 +1,62 @@ -import { Component, OnDestroy, OnInit, Input, OnChanges, SimpleChanges, EventEmitter, Output } from '@angular/core'; -import { UntypedFormBuilder } from '@angular/forms'; -import { Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; +import { + ReactiveFormsModule, + UntypedFormBuilder, +} from '@angular/forms'; +import { + Router, + RouterLink, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { + EPersonListActionConfig, + MembersListComponent, +} from '../../../../access-control/group-registry/group-form/members-list/members-list.component'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model'; import { Group } from '../../../../core/eperson/models/group.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; -import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model'; -import { EPerson } from '../../../../core/eperson/models/eperson.model'; -import { Observable, of as observableOf } from 'rxjs'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; +import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { hasValue } from '../../../../shared/empty.util'; -import { PaginatedList } from '../../../../core/data/paginated-list.model'; -import { - MembersListComponent, - EPersonListActionConfig, -} from '../../../../access-control/group-registry/group-form/members-list/members-list.component'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; /** * Keys to keep track of specific subscriptions */ enum SubKey { ActiveGroup, - MembersDTO, - SearchResultsDTO, + Members, + SearchResults, } /** @@ -35,6 +66,19 @@ enum SubKey { selector: 'ds-reviewers-list', // templateUrl: './reviewers-list.component.html', templateUrl: '../../../../access-control/group-registry/group-form/members-list/members-list.component.html', + standalone: true, + imports: [ + TranslateModule, + ContextHelpDirective, + ReactiveFormsModule, + PaginationComponent, + NgIf, + AsyncPipe, + RouterLink, + NgClass, + NgForOf, + BtnDisabledDirective, + ], }) export class ReviewersListComponent extends MembersListComponent implements OnInit, OnChanges, OnDestroy { @@ -50,7 +94,7 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn @Output() selectedReviewersUpdated: EventEmitter = new EventEmitter(); - selectedReviewers: EpersonDtoModel[] = []; + selectedReviewers: EPerson[] = []; constructor( protected groupService: GroupDataService, @@ -65,7 +109,7 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn super(groupService, ePersonDataService, translateService, notificationsService, formBuilder, paginationService, router, dsoNameService); } - ngOnInit() { + override ngOnInit(): void { this.searchForm = this.formBuilder.group(({ scope: 'metadata', query: '', @@ -78,6 +122,7 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn if (this.groupId === null) { this.retrieveMembers(this.config.currentPage); } else { + this.unsubFrom(SubKey.ActiveGroup); this.subs.set(SubKey.ActiveGroup, this.groupService.findById(this.groupId).pipe( getFirstSucceededRemoteDataPayload(), ).subscribe((activeGroup: Group) => { @@ -100,10 +145,12 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn retrieveMembers(page: number): void { this.config.currentPage = page; if (this.groupId === null) { - this.unsubFrom(SubKey.MembersDTO); - const paginatedListOfDTOs: PaginatedList = new PaginatedList(); - paginatedListOfDTOs.page = this.selectedReviewers; - this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs); + const paginatedListOfEPersons: PaginatedList = new PaginatedList(); + paginatedListOfEPersons.page = this.selectedReviewers.map((ePerson: EPerson) => Object.assign(new EpersonDtoModel(), { + eperson: ePerson, + ableToDelete: this.isMemberOfGroup(ePerson), + })); + this.ePeopleMembersOfGroup.next(paginatedListOfEPersons); } else { super.retrieveMembers(page); } @@ -115,39 +162,36 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn * @param possibleMember The {@link EPerson} that needs to be checked */ isMemberOfGroup(possibleMember: EPerson): Observable { - return observableOf(hasValue(this.selectedReviewers.find((reviewer: EpersonDtoModel) => reviewer.eperson.id === possibleMember.id))); + return observableOf(hasValue(this.selectedReviewers.find((reviewer: EPerson) => reviewer.id === possibleMember.id))); } /** - * Removes the {@link ePerson} from the {@link selectedReviewers} + * Removes the {@link eperson} from the {@link selectedReviewers} * - * @param ePerson The {@link EpersonDtoModel} containg the {@link EPerson} to remove + * @param eperson The {@link EPerson} to remove */ - deleteMemberFromGroup(ePerson: EpersonDtoModel) { - ePerson.memberOfGroup = false; - const index = this.selectedReviewers.indexOf(ePerson); + deleteMemberFromGroup(eperson: EPerson) { + const index = this.selectedReviewers.findIndex((reviewer: EPerson) => reviewer.id === eperson.id); if (index !== -1) { this.selectedReviewers.splice(index, 1); } - this.selectedReviewersUpdated.emit(this.selectedReviewers.map((ePersonDtoModel: EpersonDtoModel) => ePersonDtoModel.eperson)); + this.retrieveMembers(this.config.currentPage); + this.selectedReviewersUpdated.emit(this.selectedReviewers); } /** - * Adds the {@link ePerson} to the {@link selectedReviewers} (or replaces it when {@link multipleReviewers} is + * Adds the {@link eperson} to the {@link selectedReviewers} (or replaces it when {@link multipleReviewers} is * `false`). Afterwards it will emit the list. * - * @param ePerson The {@link EPerson} to add to the list + * @param eperson The {@link EPerson} to add to the list */ - addMemberToGroup(ePerson: EpersonDtoModel) { - ePerson.memberOfGroup = true; + addMemberToGroup(eperson: EPerson) { if (!this.multipleReviewers) { - for (const selectedReviewer of this.selectedReviewers) { - selectedReviewer.memberOfGroup = false; - } this.selectedReviewers = []; } - this.selectedReviewers.push(ePerson); - this.selectedReviewersUpdated.emit(this.selectedReviewers.map((epersonDtoModel: EpersonDtoModel) => epersonDtoModel.eperson)); + this.selectedReviewers.push(eperson); + this.retrieveMembers(this.config.currentPage); + this.selectedReviewersUpdated.emit(this.selectedReviewers); } } diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.spec.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.spec.ts index f1beb86b983..ab1494341e8 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.spec.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.spec.ts @@ -1,27 +1,31 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Location } from '@angular/common'; -import { AdvancedWorkflowActionComponent } from './advanced-workflow-action.component'; import { Component } from '@angular/core'; -import { MockComponent } from 'ng-mocks'; -import { DSOSelectorComponent } from '../../../shared/dso-selector/dso-selector/dso-selector.component'; -import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; -import { ClaimedTaskDataServiceStub } from '../../../shared/testing/claimed-task-data-service.stub'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { of as observableOf } from 'rxjs'; -import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { RouterTestingModule } from '@angular/router/testing'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of as observableOf } from 'rxjs'; + +import { RequestService } from '../../../core/data/request.service'; import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { RouteService } from '../../../core/services/route.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; +import { DSOSelectorComponent } from '../../../shared/dso-selector/dso-selector/dso-selector.component'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ClaimedTaskDataServiceStub } from '../../../shared/testing/claimed-task-data-service.stub'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { RequestServiceStub } from '../../../shared/testing/request-service.stub'; import { routeServiceStub } from '../../../shared/testing/route-service.stub'; -import { TranslateModule } from '@ngx-translate/core'; import { WorkflowActionDataServiceStub } from '../../../shared/testing/workflow-action-data-service.stub'; -import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; import { WorkflowItemDataServiceStub } from '../../../shared/testing/workflow-item-data-service.stub'; -import { RequestService } from '../../../core/data/request.service'; -import { RequestServiceStub } from '../../../shared/testing/request-service.stub'; -import { LocationStub } from '../../../shared/testing/location.stub'; +import { WorkflowItemActionPageDirective } from '../../workflow-item-action-page.component'; +import { AdvancedWorkflowActionComponent } from './advanced-workflow-action.component'; const workflowId = '1'; @@ -30,25 +34,24 @@ describe('AdvancedWorkflowActionComponent', () => { let fixture: ComponentFixture; let claimedTaskDataService: ClaimedTaskDataServiceStub; - let location: LocationStub; let notificationService: NotificationsServiceStub; let workflowActionDataService: WorkflowActionDataServiceStub; let workflowItemDataService: WorkflowItemDataServiceStub; + let mockLocation; beforeEach(async () => { claimedTaskDataService = new ClaimedTaskDataServiceStub(); - location = new LocationStub(); notificationService = new NotificationsServiceStub(); workflowActionDataService = new WorkflowActionDataServiceStub(); workflowItemDataService = new WorkflowItemDataServiceStub(); + mockLocation = jasmine.createSpyObj(['getState']); await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), RouterTestingModule, - ], - declarations: [ TestComponent, + WorkflowItemActionPageDirective, MockComponent(DSOSelectorComponent), ], providers: [ @@ -66,14 +69,15 @@ describe('AdvancedWorkflowActionComponent', () => { }, }, { provide: ClaimedTaskDataService, useValue: claimedTaskDataService }, - { provide: Location, useValue: location }, + { provide: Location, useValue: mockLocation }, { provide: NotificationsService, useValue: notificationService }, { provide: RouteService, useValue: routeServiceStub }, { provide: WorkflowActionDataService, useValue: workflowActionDataService }, { provide: WorkflowItemDataService, useValue: workflowItemDataService }, { provide: RequestService, useClass: RequestServiceStub }, ], - }).compileComponents(); + }) + .compileComponents(); }); beforeEach(() => { @@ -117,7 +121,9 @@ describe('AdvancedWorkflowActionComponent', () => { @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: '', - template: '' + template: '', + standalone: true, + imports: [RouterTestingModule], }) class TestComponent extends AdvancedWorkflowActionComponent { diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.ts index 73fd6dc63ed..a12a40b52ae 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action/advanced-workflow-action.component.ts @@ -1,19 +1,26 @@ -import { Component, OnInit } from '@angular/core'; -import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; -import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { WorkflowItemActionPageComponent } from '../../workflow-item-action-page.component'; -import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; +import { map } from 'rxjs/operators'; + +import { RequestService } from '../../../core/data/request.service'; +import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; import { RouteService } from '../../../core/services/route.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; -import { map } from 'rxjs/operators'; import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; -import { RequestService } from '../../../core/data/request.service'; -import { Location } from '@angular/common'; +import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { WorkflowItemActionPageDirective } from '../../workflow-item-action-page.component'; /** * Abstract component for rendering an advanced claimed task's workflow page @@ -25,7 +32,7 @@ import { Location } from '@angular/common'; selector: 'ds-advanced-workflow-action', template: '', }) -export abstract class AdvancedWorkflowActionComponent extends WorkflowItemActionPageComponent implements OnInit { +export abstract class AdvancedWorkflowActionComponent extends WorkflowItemActionPageDirective implements OnInit { workflowAction$: Observable; diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.html b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.html deleted file mode 100644 index 0904d0fcde5..00000000000 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.spec.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.spec.ts index 2c12b07589f..54aae05f768 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.spec.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.spec.ts @@ -1,14 +1,29 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AdvancedWorkflowActionsLoaderComponent } from './advanced-workflow-actions-loader.component'; -import { Router } from '@angular/router'; -import { RouterStub } from '../../../shared/testing/router.stub'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { AdvancedWorkflowActionsDirective } from './advanced-workflow-actions.directive'; +/* eslint-disable max-classes-per-file */ import { - rendersAdvancedWorkflowTaskOption -} from '../../../shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator'; + ChangeDetectionStrategy, + Component, + ComponentFactoryResolver, + Directive, + Injector, + NO_ERRORS_SCHEMA, + ViewContainerRef, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { getMockThemeService } from 'src/app/shared/mocks/theme-service.mock'; +import { ThemeService } from 'src/app/shared/theme-support/theme.service'; + import { PAGE_NOT_FOUND_PATH } from '../../../app-routing-paths'; +import { DynamicComponentLoaderDirective } from '../../../shared/abstract-component-loader/dynamic-component-loader.directive'; +import { rendersAdvancedWorkflowTaskOption } from '../../../shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator'; +import { RouterStub } from '../../../shared/testing/router.stub'; +import { AdvancedWorkflowActionsLoaderComponent } from './advanced-workflow-actions-loader.component'; const ADVANCED_WORKFLOW_ACTION_TEST = 'testaction'; @@ -17,22 +32,37 @@ describe('AdvancedWorkflowActionsLoaderComponent', () => { let fixture: ComponentFixture; let router: RouterStub; + let mockComponentFactoryResolver: any; + let themeService: ThemeService; beforeEach(async () => { router = new RouterStub(); + mockComponentFactoryResolver = { + resolveComponentFactory: jasmine.createSpy('resolveComponentFactory').and.returnValue( + AdvancedWorkflowActionTestComponent, + ), + }; + themeService = getMockThemeService(); - await TestBed.configureTestingModule({ - declarations: [ - AdvancedWorkflowActionsDirective, + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule, + DynamicComponentLoaderDirective, AdvancedWorkflowActionsLoaderComponent, + AdvancedWorkflowActionTestComponent, ], providers: [ { provide: Router, useValue: router }, + { provide: ComponentFactoryResolver, useValue: mockComponentFactoryResolver }, + { provide: Injector, useValue: {} }, + ViewContainerRef, + { provide: ThemeService, useValue: themeService }, ], + schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(AdvancedWorkflowActionsLoaderComponent, { set: { changeDetection: ChangeDetectionStrategy.Default, - entryComponents: [AdvancedWorkflowActionTestComponent], }, }).compileComponents(); }); @@ -50,24 +80,24 @@ describe('AdvancedWorkflowActionsLoaderComponent', () => { describe('When the component is rendered', () => { it('should display the AdvancedWorkflowActionTestComponent when the type has been defined in a rendersAdvancedWorkflowTaskOption', () => { - spyOn(component, 'getComponentByWorkflowTaskOption').and.returnValue(AdvancedWorkflowActionTestComponent); + spyOn(component, 'getComponent').and.returnValue(AdvancedWorkflowActionTestComponent); component.ngOnInit(); fixture.detectChanges(); - expect(component.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(ADVANCED_WORKFLOW_ACTION_TEST); + expect(component.getComponent).toHaveBeenCalled(); expect(fixture.debugElement.query(By.css('#AdvancedWorkflowActionsLoaderComponent'))).not.toBeNull(); expect(router.navigate).not.toHaveBeenCalled(); }); it('should redirect to page not found when the type has not been defined in a rendersAdvancedWorkflowTaskOption', () => { - spyOn(component, 'getComponentByWorkflowTaskOption').and.returnValue(undefined); + spyOn(component, 'getComponent').and.returnValue(undefined); component.type = 'nonexistingaction'; component.ngOnInit(); fixture.detectChanges(); - expect(component.getComponentByWorkflowTaskOption).toHaveBeenCalledWith('nonexistingaction'); + expect(component.getComponent).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith([PAGE_NOT_FOUND_PATH]); }); }); @@ -78,6 +108,15 @@ describe('AdvancedWorkflowActionsLoaderComponent', () => { // eslint-disable-next-line @angular-eslint/component-selector selector: '', template: '', + standalone: true, }) class AdvancedWorkflowActionTestComponent { } + +@Directive({ + selector: '[dsAdvancedWorkflowActions]', + standalone: true, +}) +export class MockAdvancedWorkflowActionsDirective { + constructor(public viewContainerRef: ViewContainerRef) {} +} diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.ts index 32f14c015de..799e06d8637 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component.ts @@ -1,21 +1,26 @@ -import { Component, Input, ViewChild, ComponentFactoryResolver, OnInit } from '@angular/core'; -import { hasValue } from '../../../shared/empty.util'; import { - getAdvancedComponentByWorkflowTaskOption -} from '../../../shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator'; -import { AdvancedWorkflowActionsDirective } from './advanced-workflow-actions.directive'; + Component, + Input, + OnInit, +} from '@angular/core'; import { Router } from '@angular/router'; +import { AbstractComponentLoaderComponent } from 'src/app/shared/abstract-component-loader/abstract-component-loader.component'; + import { PAGE_NOT_FOUND_PATH } from '../../../app-routing-paths'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; +import { hasValue } from '../../../shared/empty.util'; +import { getAdvancedComponentByWorkflowTaskOption } from '../../../shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator'; +import { ThemeService } from '../../../shared/theme-support/theme.service'; /** * Component for loading a {@link AdvancedWorkflowActionComponent} depending on the "{@link type}" input */ @Component({ selector: 'ds-advanced-workflow-actions-loader', - templateUrl: './advanced-workflow-actions-loader.component.html', - styleUrls: ['./advanced-workflow-actions-loader.component.scss'], + templateUrl: '../../../shared/abstract-component-loader/abstract-component-loader.component.html', + standalone: true, }) -export class AdvancedWorkflowActionsLoaderComponent implements OnInit { +export class AdvancedWorkflowActionsLoaderComponent extends AbstractComponentLoaderComponent implements OnInit { /** * The name of the type to render @@ -23,35 +28,28 @@ export class AdvancedWorkflowActionsLoaderComponent implements OnInit { */ @Input() type: string; - /** - * Directive to determine where the dynamic child component is located - */ - @ViewChild(AdvancedWorkflowActionsDirective, { static: true }) claimedTaskActionsDirective: AdvancedWorkflowActionsDirective; + protected inputNames: (keyof this & string)[] = [ + ...this.inputNames, + 'type', + ]; constructor( - private componentFactoryResolver: ComponentFactoryResolver, + protected themeService: ThemeService, private router: Router, ) { + super(themeService); } - /** - * Fetch, create and initialize the relevant component - */ ngOnInit(): void { - const comp = this.getComponentByWorkflowTaskOption(this.type); - if (hasValue(comp)) { - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(comp); - - const viewContainerRef = this.claimedTaskActionsDirective.viewContainerRef; - viewContainerRef.clear(); - viewContainerRef.createComponent(componentFactory); + if (hasValue(this.getComponent())) { + super.ngOnInit(); } else { void this.router.navigate([PAGE_NOT_FOUND_PATH]); } } - getComponentByWorkflowTaskOption(type: string): any { - return getAdvancedComponentByWorkflowTaskOption(type); + public getComponent(): GenericConstructor { + return getAdvancedComponentByWorkflowTaskOption(this.type) as GenericConstructor; } } diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts deleted file mode 100644 index e569f6cc6f8..00000000000 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Directive, ViewContainerRef } from '@angular/core'; - -@Directive({ - selector: '[dsAdvancedWorkflowActions]', -}) -/** - * Directive used as a hook to know where to inject the dynamic Advanced Claimed Task Actions component - */ -export class AdvancedWorkflowActionsDirective { - - constructor( - public viewContainerRef: ViewContainerRef, - ) { - } - -} diff --git a/src/app/workflowitems-edit-page/item-from-workflow-breadcrumb.resolver.ts b/src/app/workflowitems-edit-page/item-from-workflow-breadcrumb.resolver.ts new file mode 100644 index 00000000000..1c29d6b8612 --- /dev/null +++ b/src/app/workflowitems-edit-page/item-from-workflow-breadcrumb.resolver.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; + +import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { SubmissionParentBreadcrumbResolver } from '../core/submission/resolver/submission-parent-breadcrumb.resolver'; +import { SubmissionParentBreadcrumbsService } from '../core/submission/submission-parent-breadcrumb.service'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; + +/** + * This class represents a resolver that retrieves the breadcrumbs of the workflow item + */ +@Injectable({ + providedIn: 'root', +}) +export class ItemFromWorkflowBreadcrumbResolver extends SubmissionParentBreadcrumbResolver implements Resolve> { + + constructor( + protected dataService: WorkflowItemDataService, + protected breadcrumbService: SubmissionParentBreadcrumbsService, + ) { + super(dataService, breadcrumbService); + } + +} diff --git a/src/app/workflowitems-edit-page/item-from-workflow.resolver.spec.ts b/src/app/workflowitems-edit-page/item-from-workflow.resolver.spec.ts index 1ef87ad10ff..9ee8eaed061 100644 --- a/src/app/workflowitems-edit-page/item-from-workflow.resolver.spec.ts +++ b/src/app/workflowitems-edit-page/item-from-workflow.resolver.spec.ts @@ -1,35 +1,36 @@ import { first } from 'rxjs/operators'; + import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { ItemFromWorkflowResolver } from './item-from-workflow.resolver'; +import { itemFromWorkflowResolver } from './item-from-workflow.resolver'; -describe('ItemFromWorkflowResolver', () => { +describe('itemFromWorkflowResolver', () => { describe('resolve', () => { - let resolver: ItemFromWorkflowResolver; + let resolver: any; let wfiService: WorkflowItemDataService; const uuid = '1234-65487-12354-1235'; const itemUuid = '8888-8888-8888-8888'; const wfi = { id: uuid, - item: createSuccessfulRemoteDataObject$({ id: itemUuid }) + item: createSuccessfulRemoteDataObject$({ id: itemUuid }), }; beforeEach(() => { wfiService = { - findById: (id: string) => createSuccessfulRemoteDataObject$(wfi) + findById: (id: string) => createSuccessfulRemoteDataObject$(wfi), } as any; - resolver = new ItemFromWorkflowResolver(wfiService, null); + resolver = itemFromWorkflowResolver; }); it('should resolve a an item from from the workflow item with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + resolver({ params: { id: uuid } } as any, undefined, wfiService) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(itemUuid); done(); - } + }, ); }); }); diff --git a/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts b/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts index bacf5156569..e76a147f525 100644 --- a/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts +++ b/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts @@ -1,21 +1,21 @@ -import { Injectable } from '@angular/core'; -import { Resolve } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + import { RemoteData } from '../core/data/remote-data'; import { Item } from '../core/shared/item.model'; -import { Store } from '@ngrx/store'; -import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; import { SubmissionObjectResolver } from '../core/submission/resolver/submission-object.resolver'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; -/** - * This class represents a resolver that requests a specific item before the route is activated - */ -@Injectable() -export class ItemFromWorkflowResolver extends SubmissionObjectResolver implements Resolve> { - constructor( - private workflowItemService: WorkflowItemDataService, - protected store: Store - ) { - super(workflowItemService, store); - } +export const itemFromWorkflowResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + workflowItemService: WorkflowItemDataService = inject(WorkflowItemDataService), +): Observable> => { + return SubmissionObjectResolver(route, state, workflowItemService); +}; -} diff --git a/src/app/workflowitems-edit-page/workflow-item-action-page.component.spec.ts b/src/app/workflowitems-edit-page/workflow-item-action-page.component.spec.ts index c4dea0f30cf..64fb97147ba 100644 --- a/src/app/workflowitems-edit-page/workflow-item-action-page.component.spec.ts +++ b/src/app/workflowitems-edit-page/workflow-item-action-page.component.spec.ts @@ -1,30 +1,54 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { + CommonModule, + Location, +} from '@angular/common'; +import { + Component, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { WorkflowItemActionPageComponent } from './workflow-item-action-page.component'; -import { NotificationsService } from '../shared/notifications/notifications.service'; +import { RequestService } from '../core/data/request.service'; import { RouteService } from '../core/services/route.service'; -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; import { WorkflowItem } from '../core/submission/models/workflowitem.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { VarDirective } from '../shared/utils/var.directive'; -import { By } from '@angular/platform-browser'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; +import { ModifyItemOverviewComponent } from '../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../shared/remote-data.utils'; import { ActivatedRouteStub } from '../shared/testing/active-router.stub'; -import { RouterStub } from '../shared/testing/router.stub'; +import { LocationStub } from '../shared/testing/location.stub'; import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; -import { RequestService } from '../core/data/request.service'; import { RequestServiceStub } from '../shared/testing/request-service.stub'; -import { Location } from '@angular/common'; -import { LocationStub } from '../shared/testing/location.stub'; +import { RouterStub } from '../shared/testing/router.stub'; +import { VarDirective } from '../shared/utils/var.directive'; +import { WorkflowItemActionPageDirective } from './workflow-item-action-page.component'; const type = 'testType'; describe('WorkflowItemActionPageComponent', () => { - let component: WorkflowItemActionPageComponent; - let fixture: ComponentFixture; + let component: WorkflowItemActionPageDirective; + let fixture: ComponentFixture; let wfiService; let wfi; let itemRD$; @@ -32,7 +56,7 @@ describe('WorkflowItemActionPageComponent', () => { function init() { wfiService = jasmine.createSpyObj('workflowItemService', { - sendBack: observableOf(true) + sendBack: observableOf(true), }); itemRD$ = createSuccessfulRemoteDataObject$(itemRD$); wfi = new WorkflowItem(); @@ -46,10 +70,9 @@ describe('WorkflowItemActionPageComponent', () => { imports: [TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], - declarations: [TestComponent, VarDirective], + useClass: TranslateLoaderMock, + }, + }), TestComponent, VarDirective], providers: [ { provide: ActivatedRoute, useValue: new ActivatedRouteStub({}, { wfi: createSuccessfulRemoteDataObject(wfi) }) }, { provide: Router, useClass: RouterStub }, @@ -59,7 +82,7 @@ describe('WorkflowItemActionPageComponent', () => { { provide: WorkflowItemDataService, useValue: wfiService }, { provide: RequestService, useClass: RequestServiceStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) .compileComponents(); })); @@ -106,11 +129,12 @@ describe('WorkflowItemActionPageComponent', () => { }); @Component({ - selector: 'ds-workflow-item-test-action-page', - templateUrl: 'workflow-item-action-page.component.html' - } -) -class TestComponent extends WorkflowItemActionPageComponent { + selector: 'ds-workflow-item-test-action-page', + templateUrl: 'workflow-item-action-page.component.html', + imports: [VarDirective, TranslateModule, CommonModule, ModifyItemOverviewComponent], + standalone: true, +}) +class TestComponent extends WorkflowItemActionPageDirective { constructor(protected route: ActivatedRoute, protected workflowItemService: WorkflowItemDataService, protected router: Router, diff --git a/src/app/workflowitems-edit-page/workflow-item-action-page.component.ts b/src/app/workflowitems-edit-page/workflow-item-action-page.component.ts index 2ed5639c5a7..f187d26b99a 100644 --- a/src/app/workflowitems-edit-page/workflow-item-action-page.component.ts +++ b/src/app/workflowitems-edit-page/workflow-item-action-page.component.ts @@ -1,28 +1,51 @@ -import { Component, OnInit } from '@angular/core'; import { Location } from '@angular/common'; -import { Observable, combineLatest } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; +import { + Directive, + Input, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Data, + Params, + Router, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { WorkflowItem } from '../core/submission/models/workflowitem.model'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { RemoteData } from '../core/data/remote-data'; +import { RequestService } from '../core/data/request.service'; +import { RouteService } from '../core/services/route.service'; import { Item } from '../core/shared/item.model'; -import { ActivatedRoute, Data, Router, Params } from '@angular/router'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, +} from '../core/shared/operators'; +import { WorkflowItem } from '../core/submission/models/workflowitem.model'; import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; -import { RouteService } from '../core/services/route.service'; -import { NotificationsService } from '../shared/notifications/notifications.service'; -import { RemoteData } from '../core/data/remote-data'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../core/shared/operators'; import { isEmpty } from '../shared/empty.util'; -import { RequestService } from '../core/data/request.service'; +import { NotificationsService } from '../shared/notifications/notifications.service'; /** * Abstract component representing a page to perform an action on a workflow item */ -@Component({ +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector selector: 'ds-workflowitem-action-page', - template: '' + standalone: true, }) -export abstract class WorkflowItemActionPageComponent implements OnInit { - public type; +export abstract class WorkflowItemActionPageDirective implements OnInit { + + @Input() type: string; + public wfi$: Observable; public item$: Observable; protected previousQueryParameters?: Params; @@ -45,7 +68,7 @@ export abstract class WorkflowItemActionPageComponent implements OnInit { this.type = this.getType(); this.wfi$ = this.route.data.pipe(map((data: Data) => data.wfi as RemoteData), getRemoteDataPayload()); this.item$ = this.wfi$.pipe(switchMap((wfi: WorkflowItem) => (wfi.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); - this.previousQueryParameters = (this.location.getState() as { [key: string]: any }).previousQueryParams; + this.previousQueryParameters = (this.location.getState() as { [key: string]: any })?.previousQueryParams; } /** @@ -54,7 +77,7 @@ export abstract class WorkflowItemActionPageComponent implements OnInit { performAction() { combineLatest([this.wfi$, this.requestService.removeByHrefSubstring('/discover')]).pipe( take(1), - switchMap(([wfi]) => this.sendRequest(wfi.id)) + switchMap(([wfi]) => this.sendRequest(wfi.id)), ).subscribe((successful: boolean) => { if (successful) { const title = this.translationService.get('workflow-item.' + this.type + '.notification.success.title'); @@ -76,18 +99,18 @@ export abstract class WorkflowItemActionPageComponent implements OnInit { previousPage() { this.routeService.getPreviousUrl().pipe(take(1)) .subscribe((url) => { - let params: Params = {}; - if (isEmpty(url)) { - url = '/mydspace'; - params = this.previousQueryParameters; - } - if (url.split('?').length > 1) { - for (const param of url.split('?')[1].split('&')) { - params[param.split('=')[0]] = decodeURIComponent(param.split('=')[1]); - } + let params: Params = {}; + if (isEmpty(url)) { + url = '/mydspace'; + params = this.previousQueryParameters; + } + if (url.split('?').length > 1) { + for (const param of url.split('?')[1].split('&')) { + params[param.split('=')[0]] = decodeURIComponent(param.split('=')[1]); } - void this.router.navigate([url.split('?')[0]], { queryParams: params }); } + void this.router.navigate([url.split('?')[0]], { queryParams: params }); + }, ); } diff --git a/src/app/workflowitems-edit-page/workflow-item-delete/themed-workflow-item-delete.component.ts b/src/app/workflowitems-edit-page/workflow-item-delete/themed-workflow-item-delete.component.ts index 358d26b4822..39180d1c7b2 100644 --- a/src/app/workflowitems-edit-page/workflow-item-delete/themed-workflow-item-delete.component.ts +++ b/src/app/workflowitems-edit-page/workflow-item-delete/themed-workflow-item-delete.component.ts @@ -1,15 +1,18 @@ -import { WorkflowItemDeleteComponent } from './workflow-item-delete.component'; -import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { WorkflowItemDeleteComponent } from './workflow-item-delete.component'; + /** * Themed wrapper for WorkflowItemDeleteComponent */ @Component({ - selector: 'ds-themed-workflow-item-delete', + selector: 'ds-workflow-item-delete', styleUrls: [], - templateUrl: './../../shared/theme-support/themed.component.html' + templateUrl: './../../shared/theme-support/themed.component.html', + standalone: true, + imports: [WorkflowItemDeleteComponent], }) export class ThemedWorkflowItemDeleteComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts b/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts index 89a7029b4a1..801113c4ce7 100644 --- a/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts +++ b/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts @@ -1,23 +1,37 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { Location } from '@angular/common'; -import { WorkflowItemDeleteComponent } from './workflow-item-delete.component'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RouteService } from '../../core/services/route.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; -import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { VarDirective } from '../../shared/utils/var.directive'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + import { RequestService } from '../../core/data/request.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RouteService } from '../../core/services/route.service'; +import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { LocationStub } from '../../shared/testing/location.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { WorkflowItemDeleteComponent } from './workflow-item-delete.component'; describe('WorkflowItemDeleteComponent', () => { let component: WorkflowItemDeleteComponent; @@ -29,7 +43,7 @@ describe('WorkflowItemDeleteComponent', () => { function init() { wfiService = jasmine.createSpyObj('workflowItemService', { - delete: observableOf(true) + delete: observableOf(true), }); itemRD$ = createSuccessfulRemoteDataObject$(itemRD$); wfi = new WorkflowItem(); @@ -43,10 +57,9 @@ describe('WorkflowItemDeleteComponent', () => { imports: [TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], - declarations: [WorkflowItemDeleteComponent, VarDirective], + useClass: TranslateLoaderMock, + }, + }), WorkflowItemDeleteComponent, VarDirective], providers: [ { provide: ActivatedRoute, useValue: new ActivatedRouteStub({}, { wfi: createSuccessfulRemoteDataObject(wfi) }) }, { provide: Router, useClass: RouterStub }, @@ -56,7 +69,7 @@ describe('WorkflowItemDeleteComponent', () => { { provide: WorkflowItemDataService, useValue: wfiService }, { provide: RequestService, useValue: getMockRequestService() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) .compileComponents(); })); diff --git a/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts b/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts index 011398369db..0352eba098b 100644 --- a/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts +++ b/src/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts @@ -1,26 +1,40 @@ +import { + CommonModule, + Location, +} from '@angular/common'; import { Component } from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { WorkflowItemActionPageComponent } from '../workflow-item-action-page.component'; -import { ActivatedRoute, Router } from '@angular/router'; -import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; -import { RouteService } from '../../core/services/route.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; import { map } from 'rxjs/operators'; + import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; +import { RouteService } from '../../core/services/route.service'; import { NoContent } from '../../core/shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { Location } from '@angular/common'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { ModifyItemOverviewComponent } from '../../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { WorkflowItemActionPageDirective } from '../workflow-item-action-page.component'; @Component({ - selector: 'ds-workflow-item-delete', - templateUrl: '../workflow-item-action-page.component.html' + selector: 'ds-base-workflow-item-delete', + templateUrl: '../workflow-item-action-page.component.html', + standalone: true, + imports: [VarDirective, TranslateModule, CommonModule, ModifyItemOverviewComponent], }) /** * Component representing a page to delete a workflow item */ -export class WorkflowItemDeleteComponent extends WorkflowItemActionPageComponent { +export class WorkflowItemDeleteComponent extends WorkflowItemActionPageDirective { constructor(protected route: ActivatedRoute, protected workflowItemService: WorkflowItemDataService, protected router: Router, @@ -47,7 +61,7 @@ export class WorkflowItemDeleteComponent extends WorkflowItemActionPageComponent sendRequest(id: string): Observable { return this.workflowItemService.delete(id).pipe( getFirstCompletedRemoteData(), - map((response: RemoteData) => response.hasSucceeded) + map((response: RemoteData) => response.hasSucceeded), ); } } diff --git a/src/app/workflowitems-edit-page/workflow-item-page.resolver.spec.ts b/src/app/workflowitems-edit-page/workflow-item-page.resolver.spec.ts index 8f6a1f1de09..02754776a93 100644 --- a/src/app/workflowitems-edit-page/workflow-item-page.resolver.spec.ts +++ b/src/app/workflowitems-edit-page/workflow-item-page.resolver.spec.ts @@ -1,29 +1,30 @@ import { first } from 'rxjs/operators'; -import { WorkflowItemPageResolver } from './workflow-item-page.resolver'; + import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { workflowItemPageResolver } from './workflow-item-page.resolver'; -describe('WorkflowItemPageResolver', () => { +describe('workflowItemPageResolver', () => { describe('resolve', () => { - let resolver: WorkflowItemPageResolver; + let resolver: any; let wfiService: WorkflowItemDataService; const uuid = '1234-65487-12354-1235'; beforeEach(() => { wfiService = { - findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findById: (id: string) => createSuccessfulRemoteDataObject$({ id }), } as any; - resolver = new WorkflowItemPageResolver(wfiService); + resolver = workflowItemPageResolver; }); it('should resolve a workflow item with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + resolver({ params: { id: uuid } } as any, undefined, wfiService) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/workflowitems-edit-page/workflow-item-page.resolver.ts b/src/app/workflowitems-edit-page/workflow-item-page.resolver.ts index 4bb3eac5131..09aa91124b4 100644 --- a/src/app/workflowitems-edit-page/workflow-item-page.resolver.ts +++ b/src/app/workflowitems-edit-page/workflow-item-page.resolver.ts @@ -1,34 +1,28 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; + import { RemoteData } from '../core/data/remote-data'; -import { followLink } from '../shared/utils/follow-link-config.model'; -import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; -import { WorkflowItem } from '../core/submission/models/workflowitem.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { WorkflowItem } from '../core/submission/models/workflowitem.model'; +import { SUBMISSION_LINKS_TO_FOLLOW } from '../core/submission/resolver/submission-links-to-follow'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; -/** - * This class represents a resolver that requests a specific workflow item before the route is activated - */ -@Injectable() -export class WorkflowItemPageResolver implements Resolve> { - constructor(private workflowItemService: WorkflowItemDataService) { - } - - /** - * Method for resolving a workflow item based on the parameters in the current route - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found workflow item based on the parameters in the current route, - * or an error if something went wrong - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.workflowItemService.findById(route.params.id, - true, - false, - followLink('item'), - ).pipe( - getFirstCompletedRemoteData(), - ); - } -} +export const workflowItemPageResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + workflowItemService: WorkflowItemDataService = inject(WorkflowItemDataService), +): Observable> => { + return workflowItemService.findById( + route.params.id, + true, + false, + ...SUBMISSION_LINKS_TO_FOLLOW, + ).pipe( + getFirstCompletedRemoteData(), + ); +}; diff --git a/src/app/workflowitems-edit-page/workflow-item-send-back/themed-workflow-item-send-back.component.ts b/src/app/workflowitems-edit-page/workflow-item-send-back/themed-workflow-item-send-back.component.ts index 5a3e9ca4762..72edd8147e9 100644 --- a/src/app/workflowitems-edit-page/workflow-item-send-back/themed-workflow-item-send-back.component.ts +++ b/src/app/workflowitems-edit-page/workflow-item-send-back/themed-workflow-item-send-back.component.ts @@ -1,5 +1,6 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { Component } from '@angular/core'; + +import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { WorkflowItemSendBackComponent } from './workflow-item-send-back.component'; /** @@ -7,9 +8,11 @@ import { WorkflowItemSendBackComponent } from './workflow-item-send-back.compone */ @Component({ - selector: 'ds-themed-workflow-item-send-back', + selector: 'ds-workflow-item-send-back', styleUrls: [], - templateUrl: './../../shared/theme-support/themed.component.html' + templateUrl: './../../shared/theme-support/themed.component.html', + standalone: true, + imports: [WorkflowItemSendBackComponent], }) export class ThemedWorkflowItemSendBackComponent extends ThemedComponent { protected getComponentName(): string { diff --git a/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts b/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts index 1196e05593d..2a25bc0cc6c 100644 --- a/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts +++ b/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts @@ -1,23 +1,37 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { Location } from '@angular/common'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RouteService } from '../../core/services/route.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; -import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { VarDirective } from '../../shared/utils/var.directive'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { WorkflowItemSendBackComponent } from './workflow-item-send-back.component'; + import { RequestService } from '../../core/data/request.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { RouteService } from '../../core/services/route.service'; +import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { LocationStub } from '../../shared/testing/location.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { WorkflowItemSendBackComponent } from './workflow-item-send-back.component'; describe('WorkflowItemSendBackComponent', () => { let component: WorkflowItemSendBackComponent; @@ -29,7 +43,7 @@ describe('WorkflowItemSendBackComponent', () => { function init() { wfiService = jasmine.createSpyObj('workflowItemService', { - sendBack: observableOf(true) + sendBack: observableOf(true), }); itemRD$ = createSuccessfulRemoteDataObject$(itemRD$); wfi = new WorkflowItem(); @@ -43,10 +57,9 @@ describe('WorkflowItemSendBackComponent', () => { imports: [TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], - declarations: [WorkflowItemSendBackComponent, VarDirective], + useClass: TranslateLoaderMock, + }, + }), WorkflowItemSendBackComponent, VarDirective], providers: [ { provide: ActivatedRoute, useValue: new ActivatedRouteStub({}, { wfi: createSuccessfulRemoteDataObject(wfi) }) }, { provide: Router, useClass: RouterStub }, @@ -56,7 +69,7 @@ describe('WorkflowItemSendBackComponent', () => { { provide: WorkflowItemDataService, useValue: wfiService }, { provide: RequestService, useValue: getMockRequestService() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) .compileComponents(); })); diff --git a/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts b/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts index a3c03bcfb1f..ad0b1d91a83 100644 --- a/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts +++ b/src/app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts @@ -1,22 +1,36 @@ +import { + CommonModule, + Location, +} from '@angular/common'; import { Component } from '@angular/core'; -import { WorkflowItemActionPageComponent } from '../workflow-item-action-page.component'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { ActivatedRoute, Router } from '@angular/router'; -import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; + +import { RequestService } from '../../core/data/request.service'; import { RouteService } from '../../core/services/route.service'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { ModifyItemOverviewComponent } from '../../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; -import { Location } from '@angular/common'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { WorkflowItemActionPageDirective } from '../workflow-item-action-page.component'; @Component({ - selector: 'ds-workflow-item-send-back', - templateUrl: '../workflow-item-action-page.component.html' + selector: 'ds-base-workflow-item-send-back', + templateUrl: '../workflow-item-action-page.component.html', + standalone: true, + imports: [VarDirective, TranslateModule, CommonModule, ModifyItemOverviewComponent], }) /** * Component representing a page to send back a workflow item to the submitter */ -export class WorkflowItemSendBackComponent extends WorkflowItemActionPageComponent { +export class WorkflowItemSendBackComponent extends WorkflowItemActionPageDirective { constructor(protected route: ActivatedRoute, protected workflowItemService: WorkflowItemDataService, protected router: Router, diff --git a/src/app/workflowitems-edit-page/workflowitems-edit-page-routes.ts b/src/app/workflowitems-edit-page/workflowitems-edit-page-routes.ts new file mode 100644 index 00000000000..4bc074c4256 --- /dev/null +++ b/src/app/workflowitems-edit-page/workflowitems-edit-page-routes.ts @@ -0,0 +1,81 @@ +import { Routes } from '@angular/router'; + +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ThemedFullItemPageComponent } from '../item-page/full/themed-full-item-page.component'; +import { ThemedSubmissionEditComponent } from '../submission/edit/themed-submission-edit.component'; +import { AdvancedWorkflowActionPageComponent } from './advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component'; +import { itemFromWorkflowResolver } from './item-from-workflow.resolver'; +import { ItemFromWorkflowBreadcrumbResolver } from './item-from-workflow-breadcrumb.resolver'; +import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component'; +import { workflowItemPageResolver } from './workflow-item-page.resolver'; +import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/themed-workflow-item-send-back.component'; +import { + ADVANCED_WORKFLOW_PATH, + WORKFLOW_ITEM_DELETE_PATH, + WORKFLOW_ITEM_EDIT_PATH, + WORKFLOW_ITEM_SEND_BACK_PATH, + WORKFLOW_ITEM_VIEW_PATH, +} from './workflowitems-edit-page-routing-paths'; + +export const ROUTES: Routes = [ + { + path: ':id', + resolve: { + breadcrumb: ItemFromWorkflowBreadcrumbResolver, + wfi: workflowItemPageResolver, + }, + children: [ + { + canActivate: [authenticatedGuard], + path: WORKFLOW_ITEM_EDIT_PATH, + component: ThemedSubmissionEditComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + title: 'workflow-item.edit.title', + breadcrumbKey: 'workflow-item.edit', + collectionModifiable: false, + }, + }, + { + canActivate: [authenticatedGuard], + path: WORKFLOW_ITEM_VIEW_PATH, + component: ThemedFullItemPageComponent, + resolve: { + dso: itemFromWorkflowResolver, + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'workflow-item.view.title', breadcrumbKey: 'workflow-item.view' }, + }, + { + canActivate: [authenticatedGuard], + path: WORKFLOW_ITEM_DELETE_PATH, + component: ThemedWorkflowItemDeleteComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'workflow-item.delete.title', breadcrumbKey: 'workflow-item.edit' }, + }, + { + canActivate: [authenticatedGuard], + path: WORKFLOW_ITEM_SEND_BACK_PATH, + component: ThemedWorkflowItemSendBackComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'workflow-item.send-back.title', breadcrumbKey: 'workflow-item.edit' }, + }, + { + canActivate: [authenticatedGuard], + path: ADVANCED_WORKFLOW_PATH, + component: AdvancedWorkflowActionPageComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { title: 'workflow-item.advanced.title', breadcrumbKey: 'workflow-item.edit' }, + }, + ], + }, +]; diff --git a/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts b/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts index 326eebe4a79..1b21174c604 100644 --- a/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts +++ b/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts @@ -1,5 +1,8 @@ +import { + getWorkflowItemModuleRoute, + getWorkspaceItemModuleRoute, +} from '../app-routing-paths'; import { URLCombiner } from '../core/url-combiner/url-combiner'; -import { getWorkflowItemModuleRoute, getWorkspaceItemModuleRoute } from '../app-routing-paths'; export function getWorkflowItemPageRoute(wfiId: string) { return new URLCombiner(getWorkflowItemModuleRoute(), wfiId).toString(); @@ -28,9 +31,14 @@ export function getWorkspaceItemDeleteRoute(wsiId: string) { return new URLCombiner(getWorkspaceItemModuleRoute(), wsiId, WORKSPACE_ITEM_DELETE_PATH).toString(); } +export function getWorkspaceItemEditRoute(wsiId: string) { + return new URLCombiner(getWorkspaceItemModuleRoute(), wsiId, WORKSPACE_ITEM_EDIT_PATH).toString(); +} + export const WORKFLOW_ITEM_EDIT_PATH = 'edit'; export const WORKFLOW_ITEM_DELETE_PATH = 'delete'; export const WORKFLOW_ITEM_VIEW_PATH = 'view'; export const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; export const ADVANCED_WORKFLOW_PATH = 'advanced'; export const WORKSPACE_ITEM_DELETE_PATH = 'delete'; +export const WORKSPACE_ITEM_EDIT_PATH = 'edit'; diff --git a/src/app/workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/workflowitems-edit-page/workflowitems-edit-page-routing.module.ts deleted file mode 100644 index b093f205637..00000000000 --- a/src/app/workflowitems-edit-page/workflowitems-edit-page-routing.module.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { WorkflowItemPageResolver } from './workflow-item-page.resolver'; -import { - WORKFLOW_ITEM_DELETE_PATH, - WORKFLOW_ITEM_EDIT_PATH, - WORKFLOW_ITEM_SEND_BACK_PATH, - WORKFLOW_ITEM_VIEW_PATH, - ADVANCED_WORKFLOW_PATH, -} from './workflowitems-edit-page-routing-paths'; -import { ThemedSubmissionEditComponent } from '../submission/edit/themed-submission-edit.component'; -import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component'; -import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/themed-workflow-item-send-back.component'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { ItemFromWorkflowResolver } from './item-from-workflow.resolver'; -import { ThemedFullItemPageComponent } from '../item-page/full/themed-full-item-page.component'; -import { - AdvancedWorkflowActionPageComponent -} from './advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component'; - -@NgModule({ - imports: [ - RouterModule.forChild([ - { - path: ':id', - resolve: { wfi: WorkflowItemPageResolver }, - children: [ - { - canActivate: [AuthenticatedGuard], - path: WORKFLOW_ITEM_EDIT_PATH, - component: ThemedSubmissionEditComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { - title: 'workflow-item.edit.title', - breadcrumbKey: 'workflow-item.edit', - collectionModifiable: false - } - }, - { - canActivate: [AuthenticatedGuard], - path: WORKFLOW_ITEM_VIEW_PATH, - component: ThemedFullItemPageComponent, - resolve: { - dso: ItemFromWorkflowResolver, - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'workflow-item.view.title', breadcrumbKey: 'workflow-item.view' } - }, - { - canActivate: [AuthenticatedGuard], - path: WORKFLOW_ITEM_DELETE_PATH, - component: ThemedWorkflowItemDeleteComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'workflow-item.delete.title', breadcrumbKey: 'workflow-item.edit' } - }, - { - canActivate: [AuthenticatedGuard], - path: WORKFLOW_ITEM_SEND_BACK_PATH, - component: ThemedWorkflowItemSendBackComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'workflow-item.send-back.title', breadcrumbKey: 'workflow-item.edit' } - }, - { - canActivate: [AuthenticatedGuard], - path: ADVANCED_WORKFLOW_PATH, - component: AdvancedWorkflowActionPageComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'workflow-item.advanced.title', breadcrumbKey: 'workflow-item.edit' } - }, - ] - }] - ) - ], - providers: [WorkflowItemPageResolver, ItemFromWorkflowResolver] -}) -/** - * This module defines the default component to load when navigating to the workflowitems edit page path. - */ -export class WorkflowItemsEditPageRoutingModule { -} diff --git a/src/app/workflowitems-edit-page/workflowitems-edit-page.module.ts b/src/app/workflowitems-edit-page/workflowitems-edit-page.module.ts deleted file mode 100644 index cf998c52743..00000000000 --- a/src/app/workflowitems-edit-page/workflowitems-edit-page.module.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { SharedModule } from '../shared/shared.module'; -import { WorkflowItemsEditPageRoutingModule } from './workflowitems-edit-page-routing.module'; -import { SubmissionModule } from '../submission/submission.module'; -import { WorkflowItemDeleteComponent } from './workflow-item-delete/workflow-item-delete.component'; -import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component'; -import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component'; -import { - ThemedWorkflowItemSendBackComponent -} from './workflow-item-send-back/themed-workflow-item-send-back.component'; -import { StatisticsModule } from '../statistics/statistics.module'; -import { ItemPageModule } from '../item-page/item-page.module'; -import { - AdvancedWorkflowActionsLoaderComponent -} from './advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions-loader.component'; -import { - AdvancedWorkflowActionRatingComponent -} from './advanced-workflow-action/advanced-workflow-action-rating/advanced-workflow-action-rating.component'; -import { - AdvancedWorkflowActionSelectReviewerComponent -} from './advanced-workflow-action/advanced-workflow-action-select-reviewer/advanced-workflow-action-select-reviewer.component'; -import { - AdvancedWorkflowActionPageComponent -} from './advanced-workflow-action/advanced-workflow-action-page/advanced-workflow-action-page.component'; -import { - AdvancedWorkflowActionsDirective -} from './advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive'; -import { AccessControlModule } from '../access-control/access-control.module'; -import { - ReviewersListComponent -} from './advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component'; -import { FormModule } from '../shared/form/form.module'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -@NgModule({ - imports: [ - WorkflowItemsEditPageRoutingModule, - CommonModule, - SharedModule, - SubmissionModule, - StatisticsModule, - ItemPageModule, - AccessControlModule, - FormModule, - NgbModule, - ], - declarations: [ - WorkflowItemDeleteComponent, - ThemedWorkflowItemDeleteComponent, - WorkflowItemSendBackComponent, - ThemedWorkflowItemSendBackComponent, - AdvancedWorkflowActionsLoaderComponent, - AdvancedWorkflowActionRatingComponent, - AdvancedWorkflowActionSelectReviewerComponent, - AdvancedWorkflowActionPageComponent, - AdvancedWorkflowActionsDirective, - ReviewersListComponent, - ] -}) -/** - * This module handles all modules that need to access the workflowitems edit page. - */ -export class WorkflowItemsEditPageModule { - -} diff --git a/src/app/workspaceitems-edit-page/item-from-workspace-breadcrumb.resolver.ts b/src/app/workspaceitems-edit-page/item-from-workspace-breadcrumb.resolver.ts new file mode 100644 index 00000000000..912d578b454 --- /dev/null +++ b/src/app/workspaceitems-edit-page/item-from-workspace-breadcrumb.resolver.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; + +import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { SubmissionParentBreadcrumbResolver } from '../core/submission/resolver/submission-parent-breadcrumb.resolver'; +import { SubmissionParentBreadcrumbsService } from '../core/submission/submission-parent-breadcrumb.service'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; + +/** + * This class represents a resolver that retrieves the breadcrumbs of the workspace item + */ +@Injectable({ + providedIn: 'root', +}) +export class ItemFromWorkspaceBreadcrumbResolver extends SubmissionParentBreadcrumbResolver implements Resolve> { + + constructor( + protected dataService: WorkspaceitemDataService, + protected breadcrumbService: SubmissionParentBreadcrumbsService, + ) { + super(dataService, breadcrumbService); + } + +} diff --git a/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts index c14344d70da..0dc9ad343df 100644 --- a/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts +++ b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts @@ -1,35 +1,36 @@ import { first } from 'rxjs/operators'; + import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { ItemFromWorkspaceResolver } from './item-from-workspace.resolver'; +import { itemFromWorkspaceResolver } from './item-from-workspace.resolver'; -describe('ItemFromWorkspaceResolver', () => { +describe('itemFromWorkspaceResolver', () => { describe('resolve', () => { - let resolver: ItemFromWorkspaceResolver; + let resolver: any; let wfiService: WorkspaceitemDataService; const uuid = '1234-65487-12354-1235'; const itemUuid = '8888-8888-8888-8888'; const wfi = { id: uuid, - item: createSuccessfulRemoteDataObject$({ id: itemUuid }) + item: createSuccessfulRemoteDataObject$({ id: itemUuid }), }; beforeEach(() => { wfiService = { - findById: (id: string) => createSuccessfulRemoteDataObject$(wfi) + findById: (id: string) => createSuccessfulRemoteDataObject$(wfi), } as any; - resolver = new ItemFromWorkspaceResolver(wfiService, null); + resolver = itemFromWorkspaceResolver; }); it('should resolve a an item from from the workflow item with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + resolver({ params: { id: uuid } } as any, undefined, wfiService) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(itemUuid); done(); - } + }, ); }); }); diff --git a/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts index 60e1fe6a87a..6e43fc7bea2 100644 --- a/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts +++ b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts @@ -1,21 +1,23 @@ -import { Injectable } from '@angular/core'; -import { Resolve } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + import { RemoteData } from '../core/data/remote-data'; import { Item } from '../core/shared/item.model'; -import { Store } from '@ngrx/store'; import { SubmissionObjectResolver } from '../core/submission/resolver/submission-object.resolver'; import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; /** - * This class represents a resolver that requests a specific item before the route is activated + * This method represents a resolver that requests a specific item before the route is activated */ -@Injectable() -export class ItemFromWorkspaceResolver extends SubmissionObjectResolver implements Resolve> { - constructor( - private workspaceItemService: WorkspaceitemDataService, - protected store: Store - ) { - super(workspaceItemService, store); - } - -} +export const itemFromWorkspaceResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + workspaceItemService: WorkspaceitemDataService = inject(WorkspaceitemDataService), +): Observable> => { + return SubmissionObjectResolver(route, state, workspaceItemService); +}; diff --git a/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts index bbd3360db48..4bf44de9265 100644 --- a/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts +++ b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts @@ -1,29 +1,30 @@ import { first } from 'rxjs/operators'; -import { WorkspaceItemPageResolver } from './workspace-item-page.resolver'; + import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { workspaceItemPageResolver } from './workspace-item-page.resolver'; -describe('WorkflowItemPageResolver', () => { +describe('workspaceItemPageResolver', () => { describe('resolve', () => { - let resolver: WorkspaceItemPageResolver; + let resolver: any; let wsiService: WorkspaceitemDataService; const uuid = '1234-65487-12354-1235'; beforeEach(() => { wsiService = { - findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findById: (id: string) => createSuccessfulRemoteDataObject$({ id }), } as any; - resolver = new WorkspaceItemPageResolver(wsiService); + resolver = workspaceItemPageResolver; }); it('should resolve a workspace item with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + resolver({ params: { id: uuid } } as any, undefined, wsiService) .pipe(first()) .subscribe( (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts index 1b1aa25492e..e8d781b9485 100644 --- a/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts +++ b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts @@ -1,34 +1,35 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; + import { RemoteData } from '../core/data/remote-data'; -import { followLink } from '../shared/utils/follow-link-config.model'; -import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; -import { WorkflowItem } from '../core/submission/models/workflowitem.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { WorkspaceItem } from '../core/submission/models/workspaceitem.model'; +import { SUBMISSION_LINKS_TO_FOLLOW } from '../core/submission/resolver/submission-links-to-follow'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; /** - * This class represents a resolver that requests a specific workflow item before the route is activated + * Method for resolving a workflow item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @param {WorkspaceitemDataService} workspaceItemService + * @returns Observable<> Emits the found workflow item based on the parameters in the current route, + * or an error if something went wrong */ -@Injectable() -export class WorkspaceItemPageResolver implements Resolve> { - constructor(private workspaceItemService: WorkspaceitemDataService) { - } - - /** - * Method for resolving a workflow item based on the parameters in the current route - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found workflow item based on the parameters in the current route, - * or an error if something went wrong - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.workspaceItemService.findById(route.params.id, - true, - false, - followLink('item'), - ).pipe( - getFirstCompletedRemoteData(), - ); - } -} +export const workspaceItemPageResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + workspaceItemService: WorkspaceitemDataService = inject(WorkspaceitemDataService), +): Observable> => { + return workspaceItemService.findById(route.params.id, + true, + false, + ...SUBMISSION_LINKS_TO_FOLLOW, + ).pipe( + getFirstCompletedRemoteData(), + ); +}; diff --git a/src/app/workspaceitems-edit-page/workspaceitems-delete-page/themed-workspaceitems-delete-page.component.ts b/src/app/workspaceitems-edit-page/workspaceitems-delete-page/themed-workspaceitems-delete-page.component.ts index 681cba21c86..b7e1858b79a 100644 --- a/src/app/workspaceitems-edit-page/workspaceitems-delete-page/themed-workspaceitems-delete-page.component.ts +++ b/src/app/workspaceitems-edit-page/workspaceitems-delete-page/themed-workspaceitems-delete-page.component.ts @@ -1,15 +1,17 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { Component } from '@angular/core'; + +import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { WorkspaceItemsDeletePageComponent } from './workspaceitems-delete-page.component'; /** - * Themed wrapper for WorkspaceItemsDeletePageComponent + * Themed wrapper for {@link WorkspaceItemsDeletePageComponent} */ - @Component({ - selector: 'ds-themed-workspace-items-delete', + selector: 'ds-workspace-items-delete', styleUrls: [], - templateUrl: './../../shared/theme-support/themed.component.html' + templateUrl: './../../shared/theme-support/themed.component.html', + standalone: true, + imports: [WorkspaceItemsDeletePageComponent], }) export class ThemedWorkspaceItemsDeletePageComponent extends ThemedComponent { protected getComponentName(): string { @@ -17,10 +19,10 @@ export class ThemedWorkspaceItemsDeletePageComponent extends ThemedComponent { - return import(`../../../themes/${themeName}/app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component`); + return import(`../../../themes/${themeName}/app/workspaceitems-edit-page/workspaceitems-delete-page/workspaceitems-delete-page.component`); } protected importUnthemedComponent(): Promise { - return import(`./workspaceitems-delete-page.component`); + return import('./workspaceitems-delete-page.component'); } } diff --git a/src/app/workspaceitems-edit-page/workspaceitems-delete-page/workspaceitems-delete-page.component.html b/src/app/workspaceitems-edit-page/workspaceitems-delete-page/workspaceitems-delete-page.component.html index a0f0a1711ec..75c265b4200 100644 --- a/src/app/workspaceitems-edit-page/workspaceitems-delete-page/workspaceitems-delete-page.component.html +++ b/src/app/workspaceitems-edit-page/workspaceitems-delete-page/workspaceitems-delete-page.component.html @@ -1,13 +1,13 @@
-

{{ 'workspace-item.delete.header' | translate }}

- +

{{ 'workspace-item.delete.header' | translate }}

+
diff --git a/src/themes/dspace/app/header/header.component.scss b/src/themes/dspace/app/header/header.component.scss index 2fc857826f9..5aae8af0171 100644 --- a/src/themes/dspace/app/header/header.component.scss +++ b/src/themes/dspace/app/header/header.component.scss @@ -1,26 +1,28 @@ -@media screen and (min-width: map-get($grid-breakpoints, md)) { - nav.navbar { - display: none; - } - .header { +:host { + #main-site-header { + min-height: var(--ds-header-height); + + @include media-breakpoint-up(md) { + height: var(--ds-header-height); + } + background-color: var(--ds-header-bg); + + &-container { + min-height: var(--ds-header-height); + } } -} -.navbar-brand img { - @media screen and (max-width: map-get($grid-breakpoints, md)) { - height: var(--ds-header-logo-height-xs); + img#header-logo { + height: var(--ds-header-logo-height); } -} -.navbar-toggler .navbar-toggler-icon { - background-image: none !important; - line-height: 1.5; -} -.navbar-toggler { - color: var(--ds-header-icon-color); + button#navbar-toggler { + color: var(--ds-header-icon-color); - &:hover, &:focus { - color: var(--ds-header-icon-color-hover); + &:hover, &:focus { + color: var(--ds-header-icon-color-hover); + } } + } diff --git a/src/themes/dspace/app/header/header.component.ts b/src/themes/dspace/app/header/header.component.ts index 6da89b47d57..19318389231 100644 --- a/src/themes/dspace/app/header/header.component.ts +++ b/src/themes/dspace/app/header/header.component.ts @@ -1,13 +1,39 @@ -import { Component } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { ThemedLangSwitchComponent } from 'src/app/shared/lang-switch/themed-lang-switch.component'; + +import { ContextHelpToggleComponent } from '../../../../app/header/context-help-toggle/context-help-toggle.component'; import { HeaderComponent as BaseComponent } from '../../../../app/header/header.component'; +import { ThemedNavbarComponent } from '../../../../app/navbar/themed-navbar.component'; +import { ThemedSearchNavbarComponent } from '../../../../app/search-navbar/themed-search-navbar.component'; +import { ThemedAuthNavMenuComponent } from '../../../../app/shared/auth-nav-menu/themed-auth-nav-menu.component'; +import { ImpersonateNavbarComponent } from '../../../../app/shared/impersonate-navbar/impersonate-navbar.component'; /** * Represents the header with the logo and simple navigation */ @Component({ - selector: 'ds-header', + selector: 'ds-themed-header', styleUrls: ['header.component.scss'], templateUrl: 'header.component.html', + standalone: true, + imports: [NgbDropdownModule, ThemedLangSwitchComponent, RouterLink, ThemedSearchNavbarComponent, ContextHelpToggleComponent, ThemedAuthNavMenuComponent, ImpersonateNavbarComponent, ThemedNavbarComponent, TranslateModule, AsyncPipe, NgIf], }) -export class HeaderComponent extends BaseComponent { +export class HeaderComponent extends BaseComponent implements OnInit { + public isNavBarCollapsed$: Observable; + + ngOnInit() { + super.ngOnInit(); + this.isNavBarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID); + } } diff --git a/src/themes/dspace/app/home-page/home-news/home-news.component.html b/src/themes/dspace/app/home-page/home-news/home-news.component.html index ef576ed99cd..b7b368ba888 100644 --- a/src/themes/dspace/app/home-page/home-news/home-news.component.html +++ b/src/themes/dspace/app/home-page/home-news/home-news.component.html @@ -1,9 +1,10 @@ -
+
-

DSpace 7

+

DSpace 8

+

This site is running DSpace 8. For more information, see the DSpace 8 Release Notes.

DSpace is the world leading open source repository platform that enables organisations to:

@@ -19,14 +20,14 @@

DSpace 7

handle.net and DataCite DOI -

Join an international community of leading institutions using DSpace.

+

Join an international community of leading institutions using DSpace.

The test user accounts below have their password set to the name of this software in lowercase.

    -
  • Demo Site Administrator = dspacedemo+admin@gmail.com
  • -
  • Demo Community Administrator = dspacedemo+commadmin@gmail.com
  • -
  • Demo Collection Administrator = dspacedemo+colladmin@gmail.com
  • -
  • Demo Submitter = dspacedemo+submit@gmail.com
  • +
  • Demo Site Administrator = dspacedemo+admin@gmail.com
  • +
  • Demo Community Administrator = dspacedemo+commadmin@gmail.com
  • +
  • Demo Collection Administrator = dspacedemo+colladmin@gmail.com
  • +
  • Demo Submitter = dspacedemo+submit@gmail.com
@@ -35,5 +36,5 @@

DSpace 7

- Photo by @inspiredimages + Photo by @inspiredimages
diff --git a/src/themes/dspace/app/home-page/home-news/home-news.component.scss b/src/themes/dspace/app/home-page/home-news/home-news.component.scss index 93ec1763f34..d00b0ec9594 100644 --- a/src/themes/dspace/app/home-page/home-news/home-news.component.scss +++ b/src/themes/dspace/app/home-page/home-news/home-news.component.scss @@ -1,10 +1,10 @@ :host { display: block; - margin-top: calc(var(--ds-content-spacing) * -1); div.background-image-container { color: white; position: relative; + font-weight: 600; .background-image > img { background-color: var(--bs-info); @@ -69,6 +69,11 @@ color: var(--ds-home-news-link-hover-color); } } + + .lead { + font-size: 1.25rem; + font-weight: 400; + } } diff --git a/src/themes/dspace/app/home-page/home-news/home-news.component.ts b/src/themes/dspace/app/home-page/home-news/home-news.component.ts index d4032011dc3..cebea38ee89 100644 --- a/src/themes/dspace/app/home-page/home-news/home-news.component.ts +++ b/src/themes/dspace/app/home-page/home-news/home-news.component.ts @@ -1,10 +1,12 @@ import { Component } from '@angular/core'; + import { HomeNewsComponent as BaseComponent } from '../../../../../app/home-page/home-news/home-news.component'; @Component({ - selector: 'ds-home-news', + selector: 'ds-themed-home-news', styleUrls: ['./home-news.component.scss'], - templateUrl: './home-news.component.html' + templateUrl: './home-news.component.html', + standalone: true, }) /** diff --git a/src/themes/dspace/app/navbar/navbar.component.html b/src/themes/dspace/app/navbar/navbar.component.html index 05afac7d7e9..6d7066a6671 100644 --- a/src/themes/dspace/app/navbar/navbar.component.html +++ b/src/themes/dspace/app/navbar/navbar.component.html @@ -1,24 +1,9 @@ - + + + + diff --git a/src/themes/dspace/app/navbar/navbar.component.scss b/src/themes/dspace/app/navbar/navbar.component.scss index 2bcd38d5f4d..32c65c8c978 100644 --- a/src/themes/dspace/app/navbar/navbar.component.scss +++ b/src/themes/dspace/app/navbar/navbar.component.scss @@ -1,56 +1,3 @@ -nav.navbar { - border-top: 1px var(--ds-header-navbar-border-top-color) solid; - border-bottom: 5px var(--ds-header-navbar-border-bottom-color) solid; - align-items: baseline; -} - -/** Mobile menu styling **/ -@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) { - .navbar { - width: 100vw; - background-color: var(--bs-white); - position: absolute; - overflow: hidden; - height: 0; - &.open { - height: 100vh; //doesn't matter because wrapper is sticky - } - } -} - -@media screen and (min-width: map-get($grid-breakpoints, md)) { - .reset-padding-md { - margin-left: calc(var(--bs-spacer) / -2); - margin-right: calc(var(--bs-spacer) / -2); - } -} - -/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */ -.navbar-expand-md.navbar-container { - @media screen and (max-width: map-get($grid-breakpoints, md)-0.02) { - > .navbar-inner-container { - padding: 0 var(--bs-spacer); - a.navbar-brand { - display: none; - } - .navbar-collapsed { - display: none; - } - } - padding: 0; - } - height: 80px; -} - -a.navbar-brand img { - max-height: var(--ds-header-logo-height); -} +:host { -.navbar-nav { - ::ng-deep a.nav-link { - color: var(--ds-navbar-link-color); - } - ::ng-deep a.nav-link:hover { - color: var(--ds-navbar-link-color-hover); - } } diff --git a/src/themes/dspace/app/navbar/navbar.component.ts b/src/themes/dspace/app/navbar/navbar.component.ts index 321351a933c..8cce626006a 100644 --- a/src/themes/dspace/app/navbar/navbar.component.ts +++ b/src/themes/dspace/app/navbar/navbar.component.ts @@ -1,15 +1,28 @@ +import { + AsyncPipe, + NgClass, + NgComponentOutlet, + NgFor, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + import { NavbarComponent as BaseComponent } from '../../../../app/navbar/navbar.component'; import { slideMobileNav } from '../../../../app/shared/animations/slide'; +import { ThemedUserMenuComponent } from '../../../../app/shared/auth-nav-menu/user-menu/themed-user-menu.component'; /** * Component representing the public navbar */ @Component({ - selector: 'ds-navbar', + selector: 'ds-themed-navbar', styleUrls: ['./navbar.component.scss'], templateUrl: './navbar.component.html', - animations: [slideMobileNav] + animations: [slideMobileNav], + standalone: true, + imports: [NgbDropdownModule, NgClass, NgIf, ThemedUserMenuComponent, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule], }) export class NavbarComponent extends BaseComponent { } diff --git a/src/themes/dspace/assets/images/banner-half.jpg b/src/themes/dspace/assets/images/banner-half.jpg index 31610cc3502..b37534e369e 100644 Binary files a/src/themes/dspace/assets/images/banner-half.jpg and b/src/themes/dspace/assets/images/banner-half.jpg differ diff --git a/src/themes/dspace/assets/images/banner-half.webp b/src/themes/dspace/assets/images/banner-half.webp index f11cfb78595..e2272037bfe 100644 Binary files a/src/themes/dspace/assets/images/banner-half.webp and b/src/themes/dspace/assets/images/banner-half.webp differ diff --git a/src/themes/dspace/assets/images/banner.jpg b/src/themes/dspace/assets/images/banner.jpg index 5f18b6cb6d8..ea7f4701c80 100644 Binary files a/src/themes/dspace/assets/images/banner.jpg and b/src/themes/dspace/assets/images/banner.jpg differ diff --git a/src/themes/dspace/assets/images/banner.webp b/src/themes/dspace/assets/images/banner.webp index 7745766f054..437c89d0755 100644 Binary files a/src/themes/dspace/assets/images/banner.webp and b/src/themes/dspace/assets/images/banner.webp differ diff --git a/src/themes/dspace/eager-theme.module.ts b/src/themes/dspace/eager-theme.module.ts index 63dff9da317..233b08c62ce 100644 --- a/src/themes/dspace/eager-theme.module.ts +++ b/src/themes/dspace/eager-theme.module.ts @@ -1,14 +1,11 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -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 { NgModule } from '@angular/core'; + +import { RootModule } from '../../app/root.module'; import { HeaderComponent } from './app/header/header.component'; import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; -import { RootModule } from '../../app/root.module'; -import { NavbarModule } from '../../app/navbar/navbar.module'; -import { SharedBrowseByModule } from '../../app/shared/browse-by/shared-browse-by.module'; -import { ResultsBackButtonModule } from '../../app/shared/results-back-button/results-back-button.module'; +import { HomeNewsComponent } from './app/home-page/home-news/home-news.component'; +import { NavbarComponent } from './app/navbar/navbar.component'; /** * Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS. @@ -27,15 +24,11 @@ const DECLARATIONS = [ @NgModule({ imports: [ CommonModule, - SharedModule, - SharedBrowseByModule, - ResultsBackButtonModule, RootModule, - NavbarModule, + ...DECLARATIONS, ], - declarations: DECLARATIONS, providers: [ - ...ENTRY_COMPONENTS.map((component) => ({provide: component})) + ...ENTRY_COMPONENTS.map((component) => ({ provide: component })), ], }) /** diff --git a/src/themes/dspace/lazy-theme.module.ts b/src/themes/dspace/lazy-theme.module.ts index cdaae815ada..9d26b195362 100644 --- a/src/themes/dspace/lazy-theme.module.ts +++ b/src/themes/dspace/lazy-theme.module.ts @@ -1,122 +1,34 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { AdminRegistriesModule } from '../../app/admin/admin-registries/admin-registries.module'; -import { AdminSearchModule } from '../../app/admin/admin-search-page/admin-search.module'; -import { - AdminWorkflowModuleModule -} from '../../app/admin/admin-workflow-page/admin-workflow.module'; -import { - BitstreamFormatsModule -} from '../../app/admin/admin-registries/bitstream-formats/bitstream-formats.module'; -import { BrowseByModule } from '../../app/browse-by/browse-by.module'; -import { - CollectionFormModule -} from '../../app/collection-page/collection-form/collection-form.module'; -import { CommunityFormModule } from '../../app/community-page/community-form/community-form.module'; -import { CoreModule } from '../../app/core/core.module'; import { DragDropModule } from '@angular/cdk/drag-drop'; -import { EditItemPageModule } from '../../app/item-page/edit-item-page/edit-item-page.module'; -import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; -import { IdlePreloadModule } from 'angular-idle-preload'; -import { - JournalEntitiesModule -} from '../../app/entity-groups/journal-entities/journal-entities.module'; -import { MyDspaceSearchModule } from '../../app/my-dspace-page/my-dspace-search.module'; -import { MenuModule } from '../../app/shared/menu/menu.module'; -import { NavbarModule } from '../../app/navbar/navbar.module'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { ProfilePageModule } from '../../app/profile-page/profile-page.module'; -import { RegisterEmailFormModule } from '../../app/register-email-form/register-email-form.module'; -import { - ResearchEntitiesModule -} from '../../app/entity-groups/research-entities/research-entities.module'; -import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; -import { SearchPageModule } from '../../app/search-page/search-page.module'; -import { SharedModule } from '../../app/shared/shared.module'; -import { StatisticsModule } from '../../app/statistics/statistics.module'; -import { StoreModule } from '@ngrx/store'; import { StoreRouterConnectingModule } from '@ngrx/router-store'; +import { StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { HomePageModule } from '../../app/home-page/home-page.module'; -import { AppModule } from '../../app/app.module'; -import { ItemPageModule } from '../../app/item-page/item-page.module'; -import { RouterModule } from '@angular/router'; -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 { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; + import { RootModule } from '../../app/root.module'; -import { BrowseByPageModule } from '../../app/browse-by/browse-by-page.module'; -import { ResultsBackButtonModule } from '../../app/shared/results-back-button/results-back-button.module'; -import { SharedBrowseByModule } from '../../app/shared/browse-by/shared-browse-by.module'; -import { ItemVersionsModule } from '../../app/item-page/versions/item-versions.module'; -import { ItemSharedModule } from 'src/app/item-page/item-shared.module'; const DECLARATIONS = [ ]; @NgModule({ imports: [ - AdminRegistriesModule, - AdminSearchModule, - AdminWorkflowModuleModule, - AppModule, RootModule, - BitstreamFormatsModule, - BrowseByModule, - BrowseByPageModule, - ResultsBackButtonModule, - CollectionFormModule, - CollectionPageModule, CommonModule, - CommunityFormModule, - CommunityListPageModule, - CommunityPageModule, - CoreModule, DragDropModule, - ItemSharedModule, - ItemPageModule, - EditItemPageModule, - ItemVersionsModule, FormsModule, - HomePageModule, HttpClientModule, - IdlePreloadModule, - InfoModule, - JournalEntitiesModule, - MenuModule, - MyDspaceSearchModule, - NavbarModule, NgbModule, - ProfilePageModule, - RegisterEmailFormModule, - ResearchEntitiesModule, RouterModule, ScrollToModule, - SearchPageModule, - SharedModule, - SharedBrowseByModule, - StatisticsModule, - StatisticsPageModule, StoreModule, StoreRouterConnectingModule, TranslateModule, - SubmissionModule, - MyDSpacePageModule, - MyDspaceSearchModule, - SearchModule, FormsModule, - ResourcePoliciesModule, - ComcolModule, ], declarations: DECLARATIONS, }) diff --git a/src/themes/dspace/styles/_global-styles.scss b/src/themes/dspace/styles/_global-styles.scss index a7cccd430e6..d2258193269 100644 --- a/src/themes/dspace/styles/_global-styles.scss +++ b/src/themes/dspace/styles/_global-styles.scss @@ -3,7 +3,7 @@ // imports the base global style @import '../../../styles/_global-styles.scss'; -.facet-filter, .setting-option { +.facet-filter, .setting-option, .advanced-search { background-color: var(--bs-light); border-radius: var(--bs-border-radius); @@ -17,100 +17,7 @@ background-color: var(--bs-primary); } - h4 { + h4, .h4 { font-size: 1.1rem } } - -// fixing breadcrumb link colour contrast error -a.text-truncate { - color: #125774!important; -} - -// skipped header level fixes -.community-list-header { - font-size: 2rem; -} -.community-list-second-header { - font-size: 1.25rem; -} - -// fixing skipped level header on simple item view - also fixes level header issue on full item view -.simple-view-element-header { - font-size: 1.25rem; -} - -// fixing/replacing full item page table for divs -.div-table-body { - vertical-align: middle; - border-color: inherit; - color: #343a40; -} - -.div-table-body .div-table-row:nth-of-type(odd) { - background-color: #f8f9fa; -} - -.div-table-row { - width: 100%; - display: table; -} - -.div-table-column { - display: table-cell; - padding: 0.75rem; - vertical-align: top; - border-top: 1px solid #dee2e6; - width: 33.3%; -} - -.div-table-column.div-table-language { - text-align: right; - padding-right: 4rem; -} - -// fixing statistics page heading levels -.statistics-header { - font-size: 2rem; -} -.m-1 h2 { - font-size: 1.75rem; -} - -.div-table-stats-column-1 { - display: table-cell; - padding: 0.75rem; - vertical-align: top; - border-top: 1px solid #dee2e6; - width: 75%; -} -.div-table-stats-clolumn-1-bold { - font-weight: bold; -} -.div-table-stats-column-2 { - display: table-cell; - padding: 0.75rem; - vertical-align: top; - border-top: 1px solid #dee2e6; - width: 25%; - - font-size: 1rem; - line-height: 1.5; - color: #343a40; -} -.div-table-stats-clolumn-2-bold { - font-weight: bold; -} - -.card-title { - font-size: 1.5rem; -} - -.facet-filter-header { - font-size: 1.1rem; - margin: auto!important; - display: inline-block!important; -} -.setting-option h3 { - font-size: 1.1rem!important; -} diff --git a/src/themes/dspace/styles/_theme_css_variable_overrides.scss b/src/themes/dspace/styles/_theme_css_variable_overrides.scss index 516eff9f7e9..5763979e944 100644 --- a/src/themes/dspace/styles/_theme_css_variable_overrides.scss +++ b/src/themes/dspace/styles/_theme_css_variable_overrides.scss @@ -1,11 +1,29 @@ // Override or add CSS variables for your theme here :root { - --ds-header-logo-height: 40px; - --ds-banner-text-background: rgba(0, 0, 0, 0.45); + + @include media-breakpoint-up(md) { + --ds-header-logo-height: 40px; + --ds-header-height: 80px; + } + @include media-breakpoint-down(sm) { + --ds-header-logo-height: 50px; + --ds-header-height: 90px; + } + + --ds-banner-text-background: rgba(0, 0, 0, 0.58); --ds-banner-background-gradient-width: 300px; - --ds-home-news-link-color: #{$green}; - --ds-home-news-link-hover-color: #{darken($green, 15%)}; - --ds-header-navbar-border-bottom-color: #{$green}; + + --ds-header-navbar-border-bottom-height: 5px; + + /* set the next two properties as `--ds-header-navbar-border-bottom-*` + in order to keep the bottom border of the header when navbar is expanded */ + + --ds-expandable-navbar-border-top-color: #{$white}; + --ds-expandable-navbar-border-top-height: 0; + --ds-expandable-navbar-padding-top: 0; + + --ds-item-page-img-field-default-inline-height: 24px; + --ds-item-page-img-field-ror-inline-height: var(--ds-item-page-img-field-default-inline-height); } diff --git a/src/themes/dspace/styles/_theme_sass_variable_overrides.scss b/src/themes/dspace/styles/_theme_sass_variable_overrides.scss index b5799c97496..9fcb540ab6a 100644 --- a/src/themes/dspace/styles/_theme_sass_variable_overrides.scss +++ b/src/themes/dspace/styles/_theme_sass_variable_overrides.scss @@ -1,41 +1,90 @@ // DSpace works with CSS variables for its own components, and has a mapping of all bootstrap Sass // variables to CSS equivalents (see src/styles/_bootstrap_variables_mapping.scss). However Bootstrap -// still uses Sass variables internally. So if you want to override bootstrap (or other sass -// variables) you can do so here. Their CSS counterparts will include the changes you make here +// still uses Sass variables internally. So if you want to override Bootstrap (or other sass +// variables) you can do so here. Their CSS counterparts will include the changes you make here. + +// When this file is going to be compiled, internal Bootstrap variables won't have been declared yet, +// therefore if you want to use any Bootstrap variable you also need to declare it here. + +// All SASS variables from the base theme are also included here. Do not use the '!default' flag +// here if you want to override them. + + +/*** FONT FAMILIES ***/ @import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,600;0,700;0,800;1,200;1,300;1,400;1,600;1,700;1,800&display=swap'); $font-family-sans-serif: 'Nunito', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; -$navbar-dark-color: #FFFFFF; -/* Reassign color vars to semantic color scheme */ -$blue: #2b4e72 !default; -$green: #92C642 !default; -$cyan: #207698 !default; -$yellow: #ec9433 !default; -$red: #CF4444 !default; -$dark: #43515f !default; +/*** SEMANTIC COLOR SCHEME ***/ -$gray-800: #343a40 !default; -$gray-700: #495057 !default; -$gray-400: #ced4da !default; -$gray-100: #f8f9fa !default; +// Gray scale (uncomment the variables that you want to override or that you need to use in this file) +//$white: #fff; +//$gray-100: #f8f9fa; +//$gray-200: #e9ecef; +//$gray-300: #dee2e6; +//$gray-400: #ced4da; +//$gray-500: #adb5bd; +//$gray-600: #6c757d; +//$gray-700: #495057; +//$gray-800: #343a40; +//$gray-900: #212529; +//$black: #000; -$body-color: $gray-800 !default; // Bootstrap $gray-800 +// Other colors (uncomment the variables that you want to override or that you need to use in this file) +//$blue: #007bff !default; +//$indigo: #6610f2 !default; +//$purple: #6f42c1 !default; +//$pink: #e83e8c !default; +//$red: #dc3545 !default; +//$orange: #fd7e14 !default; +//$yellow: #ffc107 !default; +//$green: #28a745 !default; +//$teal: #20c997 !default; +//$cyan: #17a2b8 !default; -$table-accent-bg: $gray-100 !default; // Bootstrap $gray-100 -$table-hover-bg: $gray-400 !default; // Bootstrap $gray-400 +// Define or override other colors here +// ... -$yiq-contrasted-threshold: 170 !default; +// Override semantic colors here +$primary: #43515f; // Gray +$secondary: #495057; // As Bootstrap $gray-700 +$success: #92c642; // Lime +$info: #1e6f90; // Light blue +$warning: #ec9433; // Orange +$danger: #cf4444; // Red +$light: #f8f9fa; // As Bootstrap $gray-100 +$dark: #43515f; // Gray +// Add new semantic colors here (you don't need to add existing semantic colors) $theme-colors: ( - primary: $dark, - secondary: $gray-700, - success: $green, - info: $cyan, - warning: $yellow, - danger: $red, - light: $gray-100, - dark: $dark -) !default; + // ... +); + + +/*** OTHER BOOTSTRAP VARIABLES ***/ + +// The yiq lightness value that determines when the lightness of color changes from "dark" to "light". Acceptable values are between 0 and 255. +$yiq-contrasted-threshold: 170; + +$body-color: #343a40; // As Bootstrap $gray-800 + +$link-color: #1e6f90; // Blue green, as DSpace $info +$link-decoration: none; +$link-hover-color: darken($link-color, 15%); +$link-hover-decoration: underline; + +$table-accent-bg: #f8f9fa; // As Bootstrap $gray-100 +$table-hover-bg: #ced4da; // As Bootstrap $gray-400 + +$navbar-dark-color: #fff; + + +/*** CUSTOM DSPACE VARIABLES ***/ + +$ds-home-news-link-color: #D2FC93; +$ds-header-navbar-border-bottom-color: #92c642; + +$ds-breadcrumb-link-color: #154E66 !default; +$ds-breadcrumb-link-active-color: #040D11 !default; diff --git a/src/themes/eager-themes.module.ts b/src/themes/eager-themes.module.ts index c3b1354ccda..d5716e26c73 100644 --- a/src/themes/eager-themes.module.ts +++ b/src/themes/eager-themes.module.ts @@ -1,7 +1,9 @@ import { NgModule } from '@angular/core'; + import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module'; // import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module'; import { EagerThemeModule as EraThemeModule } from './era/eager-theme.module'; + /** * This module bundles the eager theme modules for all available themes. * Eager modules contain components that are present on every page (to speed up initial loading) @@ -12,8 +14,8 @@ import { EagerThemeModule as EraThemeModule } from './era/eager-theme.module'; @NgModule({ imports: [ // DSpaceEagerThemeModule, - EraThemeModule // CustomEagerThemeModule, + EraThemeModule ], }) export class EagerThemesModule { diff --git a/src/themes/era/app/admin/admin-import-metadata-page/metadata-import-page.component.html b/src/themes/era/app/admin/admin-import-metadata-page/metadata-import-page.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/admin/admin-import-metadata-page/metadata-import-page.component.ts b/src/themes/era/app/admin/admin-import-metadata-page/metadata-import-page.component.ts new file mode 100644 index 00000000000..79d950b1709 --- /dev/null +++ b/src/themes/era/app/admin/admin-import-metadata-page/metadata-import-page.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +import { MetadataImportPageComponent as BaseComponent } from '../../../../../app/admin/admin-import-metadata-page/metadata-import-page.component'; +import { FileDropzoneNoUploaderComponent } from '../../../../../app/shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component'; + +@Component({ + selector: 'ds-themed-metadata-import-page', + // templateUrl: './metadata-import-page.component.html', + templateUrl: '../../../../../app/admin/admin-import-metadata-page/metadata-import-page.component.html', + standalone: true, + imports: [ + FileDropzoneNoUploaderComponent, + FormsModule, + TranslateModule, + ], +}) +export class MetadataImportPageComponent extends BaseComponent { +} diff --git a/src/themes/era/app/admin/admin-search-page/admin-search-page.component.html b/src/themes/era/app/admin/admin-search-page/admin-search-page.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/admin/admin-search-page/admin-search-page.component.scss b/src/themes/era/app/admin/admin-search-page/admin-search-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/admin/admin-search-page/admin-search-page.component.ts b/src/themes/era/app/admin/admin-search-page/admin-search-page.component.ts new file mode 100644 index 00000000000..b16a544827b --- /dev/null +++ b/src/themes/era/app/admin/admin-search-page/admin-search-page.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; + +import { AdminSearchPageComponent as BaseComponent } from '../../../../../app/admin/admin-search-page/admin-search-page.component'; +import { ThemedConfigurationSearchPageComponent } from '../../../../../app/search-page/themed-configuration-search-page.component'; + +@Component({ + selector: 'ds-themed-admin-search-page', + // styleUrls: ['./admin-search-page.component.scss'], + styleUrls: ['../../../../../app/admin/admin-search-page/admin-search-page.component.scss'], + // templateUrl: './admin-search-page.component.html', + templateUrl: '../../../../../app/admin/admin-search-page/admin-search-page.component.html', + imports: [ThemedConfigurationSearchPageComponent], + standalone: true, +}) +export class AdminSearchPageComponent extends BaseComponent { +} diff --git a/src/themes/era/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/themes/era/app/admin/admin-sidebar/admin-sidebar.component.ts index 4a6eee4fdec..2d615616116 100644 --- a/src/themes/era/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/themes/era/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -1,20 +1,27 @@ +import { + AsyncPipe, + NgClass, + NgComponentOutlet, + NgFor, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { AdminSidebarComponent as BaseComponent } from '../../../../../app/admin/admin-sidebar/admin-sidebar.component'; -import { TranslateModule } from '@ngx-translate/core'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { NgIf, NgClass, NgFor, NgComponentOutlet, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/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'], - standalone: true, - imports: [NgIf, NgbDropdownModule, NgClass, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule] + selector: 'ds-themed-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'], + standalone: true, + imports: [NgIf, NgbDropdownModule, NgClass, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule], }) export class AdminSidebarComponent extends BaseComponent { } diff --git a/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.html b/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.scss b/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.ts b/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.ts new file mode 100644 index 00000000000..c5ee994c5bb --- /dev/null +++ b/src/themes/era/app/admin/admin-workflow-page/admin-workflow-page.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; + +import { AdminWorkflowPageComponent as BaseComponent } from '../../../../../app/admin/admin-workflow-page/admin-workflow-page.component'; +import { ThemedConfigurationSearchPageComponent } from '../../../../../app/search-page/themed-configuration-search-page.component'; + +@Component({ + selector: 'ds-themed-admin-workflow-page', + // styleUrls: ['./admin-workflow-page.component.scss'], + styleUrls: ['../../../../../app/admin/admin-workflow-page/admin-workflow-page.component.scss'], + // templateUrl: './admin-workflow-page.component.html', + templateUrl: '../../../../../app/admin/admin-workflow-page/admin-workflow-page.component.html', + standalone: true, + imports: [ThemedConfigurationSearchPageComponent], +}) +export class AdminWorkflowPageComponent extends BaseComponent { +} diff --git a/src/themes/era/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/themes/era/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index 0a935714dee..1fc23a1bd03 100644 --- a/src/themes/era/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/themes/era/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -1,21 +1,42 @@ -import { EditBitstreamPageComponent as BaseComponent } from '../../../../../app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { TranslateModule } from '@ngx-translate/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; import { RouterLink } from '@angular/router'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { FormModule } from '../../../../../app/shared/form/form.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EditBitstreamPageComponent as BaseComponent } from '../../../../../app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component'; +import { ErrorComponent } from '../../../../../app/shared/error/error.component'; +import { FormComponent } from '../../../../../app/shared/form/form.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { FileSizePipe } from '../../../../../app/shared/utils/file-size-pipe'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; +import { ThemedThumbnailComponent } from '../../../../../app/thumbnail/themed-thumbnail.component'; @Component({ - selector: 'ds-edit-bitstream-page', - // styleUrls: ['./edit-bitstream-page.component.scss'], - styleUrls: ['../../../../../app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss'], - // templateUrl: './edit-bitstream-page.component.html', - templateUrl: '../../../../../app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [SharedModule, NgIf, FormModule, FormsModule, ReactiveFormsModule, RouterLink, AsyncPipe, TranslateModule] + selector: 'ds-themed-edit-bitstream-page', + // styleUrls: ['./edit-bitstream-page.component.scss'], + styleUrls: ['../../../../../app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss'], + // templateUrl: './edit-bitstream-page.component.html', + templateUrl: '../../../../../app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + FormComponent, + NgIf, + VarDirective, + ThemedThumbnailComponent, + AsyncPipe, + RouterLink, + ErrorComponent, + ThemedLoadingComponent, + TranslateModule, + FileSizePipe, + ], }) export class EditBitstreamPageComponent extends BaseComponent { } diff --git a/src/themes/era/app/breadcrumbs/breadcrumbs.component.ts b/src/themes/era/app/breadcrumbs/breadcrumbs.component.ts index e3135d864de..c71caf9f023 100644 --- a/src/themes/era/app/breadcrumbs/breadcrumbs.component.ts +++ b/src/themes/era/app/breadcrumbs/breadcrumbs.component.ts @@ -1,22 +1,28 @@ +import { + AsyncPipe, + NgFor, + NgIf, + NgTemplateOutlet, +} from '@angular/common'; import { Component } from '@angular/core'; -import { BreadcrumbsComponent as BaseComponent } from '../../../../app/breadcrumbs/breadcrumbs.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { RouterLink } from '@angular/router'; -import { NgIf, NgTemplateOutlet, NgFor, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../app/shared/shared.module'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BreadcrumbsComponent as BaseComponent } from '../../../../app/breadcrumbs/breadcrumbs.component'; +import { VarDirective } from '../../../../app/shared/utils/var.directive'; /** * Component representing the breadcrumbs of a page */ @Component({ - selector: 'ds-breadcrumbs', - // templateUrl: './breadcrumbs.component.html', - templateUrl: '../../../../app/breadcrumbs/breadcrumbs.component.html', - // styleUrls: ['./breadcrumbs.component.scss'] - styleUrls: ['../../../../app/breadcrumbs/breadcrumbs.component.scss'], - standalone: true, - imports: [SharedModule, NgIf, NgTemplateOutlet, NgFor, RouterLink, NgbTooltipModule, AsyncPipe, TranslateModule] + selector: 'ds-themed-breadcrumbs', + // templateUrl: './breadcrumbs.component.html', + templateUrl: '../../../../app/breadcrumbs/breadcrumbs.component.html', + // styleUrls: ['./breadcrumbs.component.scss'] + styleUrls: ['../../../../app/breadcrumbs/breadcrumbs.component.scss'], + standalone: true, + imports: [VarDirective, NgIf, NgTemplateOutlet, NgFor, RouterLink, NgbTooltipModule, AsyncPipe, TranslateModule], }) export class BreadcrumbsComponent extends BaseComponent { } diff --git a/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.html b/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.scss b/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.ts b/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.ts new file mode 100644 index 00000000000..ffcb504a7fc --- /dev/null +++ b/src/themes/era/app/browse-by/browse-by-date/browse-by-date.component.ts @@ -0,0 +1,28 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BrowseByDateComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-date/browse-by-date.component'; +import { ThemedBrowseByComponent } from '../../../../../app/shared/browse-by/themed-browse-by.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; + +@Component({ + selector: 'ds-browse-by-date', + // styleUrls: ['./browse-by-date.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component.scss'], + // templateUrl: './browse-by-date.component.html', + templateUrl: '../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component.html', + standalone: true, + imports: [ + AsyncPipe, + NgIf, + TranslateModule, + ThemedLoadingComponent, + ThemedBrowseByComponent, + ], +}) +export class BrowseByDateComponent extends BaseComponent { +} diff --git a/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.html b/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.scss b/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts b/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts new file mode 100644 index 00000000000..9b29bf93bec --- /dev/null +++ b/src/themes/era/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts @@ -0,0 +1,28 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BrowseByMetadataComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component'; +import { ThemedBrowseByComponent } from '../../../../../app/shared/browse-by/themed-browse-by.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; + +@Component({ + selector: 'ds-browse-by-metadata', + // styleUrls: ['./browse-by-metadata.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component.scss'], + // templateUrl: './browse-by-metadata.component.html', + templateUrl: '../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component.html', + standalone: true, + imports: [ + AsyncPipe, + NgIf, + TranslateModule, + ThemedLoadingComponent, + ThemedBrowseByComponent, + ], +}) +export class BrowseByMetadataComponent extends BaseComponent { +} diff --git a/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html b/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss b/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts b/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts new file mode 100644 index 00000000000..edf2a35c744 --- /dev/null +++ b/src/themes/era/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts @@ -0,0 +1,46 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BrowseByTaxonomyComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component'; +import { ThemedBrowseByComponent } from '../../../../../app/shared/browse-by/themed-browse-by.component'; +import { ThemedComcolPageBrowseByComponent } from '../../../../../app/shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; +import { ThemedComcolPageContentComponent } from '../../../../../app/shared/comcol/comcol-page-content/themed-comcol-page-content.component'; +import { ThemedComcolPageHandleComponent } from '../../../../../app/shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; +import { ComcolPageHeaderComponent } from '../../../../../app/shared/comcol/comcol-page-header/comcol-page-header.component'; +import { ComcolPageLogoComponent } from '../../../../../app/shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { DsoEditMenuComponent } from '../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { VocabularyTreeviewComponent } from '../../../../../app/shared/form/vocabulary-treeview/vocabulary-treeview.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; + +@Component({ + selector: 'ds-browse-by-taxonomy', + // templateUrl: './browse-by-taxonomy.component.html', + templateUrl: '../../../../../app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html', + // styleUrls: ['./browse-by-taxonomy.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss'], + standalone: true, + imports: [ + VarDirective, + AsyncPipe, + ComcolPageHeaderComponent, + ComcolPageLogoComponent, + NgIf, + ThemedComcolPageHandleComponent, + ThemedComcolPageContentComponent, + DsoEditMenuComponent, + ThemedComcolPageBrowseByComponent, + TranslateModule, + ThemedLoadingComponent, + ThemedBrowseByComponent, + VocabularyTreeviewComponent, + RouterLink, + ], +}) +export class BrowseByTaxonomyComponent extends BaseComponent { +} diff --git a/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.html b/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.scss b/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.ts b/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.ts new file mode 100644 index 00000000000..6a3b612b575 --- /dev/null +++ b/src/themes/era/app/browse-by/browse-by-title/browse-by-title.component.ts @@ -0,0 +1,28 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BrowseByTitleComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-title/browse-by-title.component'; +import { ThemedBrowseByComponent } from '../../../../../app/shared/browse-by/themed-browse-by.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; + +@Component({ + selector: 'ds-browse-by-title', + // styleUrls: ['./browse-by-title.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component.scss'], + // templateUrl: './browse-by-title.component.html', + templateUrl: '../../../../../app/browse-by/browse-by-metadata/browse-by-metadata.component.html', + standalone: true, + imports: [ + AsyncPipe, + NgIf, + TranslateModule, + ThemedLoadingComponent, + ThemedBrowseByComponent, + ], +}) +export class BrowseByTitleComponent extends BaseComponent { +} diff --git a/src/themes/era/app/collection-page/collection-page.component.html b/src/themes/era/app/collection-page/collection-page.component.html index 62bd78ae445..08f338b2d8b 100644 --- a/src/themes/era/app/collection-page/collection-page.component.html +++ b/src/themes/era/app/collection-page/collection-page.component.html @@ -1,76 +1,60 @@
-
-
-
- -
-
- - - - - - +
+
+
+
+
+ + + + + + - - - - - - - - - - - - -
- -
-
- - - + + + + + + + + + +
+ +
+
+ + + - -
-

{{'collection.page.browse.recent.head' | translate}}

- - -
- - - -
-
+ + +
+ + + +
- - + +
diff --git a/src/themes/era/app/collection-page/collection-page.component.ts b/src/themes/era/app/collection-page/collection-page.component.ts index 94ec89541d2..ae41a3fb6b5 100644 --- a/src/themes/era/app/collection-page/collection-page.component.ts +++ b/src/themes/era/app/collection-page/collection-page.component.ts @@ -1,29 +1,61 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { CollectionPageComponent as BaseComponent} from '../../../../app/collection-page/collection-page.component'; -import { fadeIn, fadeInOut } from '../../../../app/shared/animations/fade'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { RouterOutlet } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { DsoPageModule } from '../../../../app/shared/dso-page/dso-page.module'; -import { ComcolModule } from '../../../../app/shared/comcol/comcol.module'; -import { StatisticsModule } from '../../../../app/statistics/statistics.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../app/shared/shared.module'; +import { CollectionPageComponent as BaseComponent } from '../../../../app/collection-page/collection-page.component'; +import { + fadeIn, + fadeInOut, +} from '../../../../app/shared/animations/fade'; +import { ThemedComcolPageBrowseByComponent } from '../../../../app/shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; +import { ThemedComcolPageContentComponent } from '../../../../app/shared/comcol/comcol-page-content/themed-comcol-page-content.component'; +import { ThemedComcolPageHandleComponent } from '../../../../app/shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; +import { ComcolPageHeaderComponent } from '../../../../app/shared/comcol/comcol-page-header/comcol-page-header.component'; +import { ComcolPageLogoComponent } from '../../../../app/shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { DsoEditMenuComponent } from '../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { ErrorComponent } from '../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../app/shared/object-collection/object-collection.component'; +import { VarDirective } from '../../../../app/shared/utils/var.directive'; @Component({ - selector: 'ds-collection-page', - templateUrl: './collection-page.component.html', - // templateUrl: '../../../../app/collection-page/collection-page.component.html', - // styleUrls: ['./collection-page.component.scss'] - styleUrls: ['../../../../app/collection-page/collection-page.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - fadeIn, - fadeInOut - ], - standalone: true, - imports: [SharedModule, NgIf, StatisticsModule, ComcolModule, DsoPageModule, AsyncPipe, TranslateModule] + selector: 'ds-themed-collection-page', + templateUrl: './collection-page.component.html', + // templateUrl: '../../../../app/collection-page/collection-page.component.html', + // styleUrls: ['./collection-page.component.scss'] + styleUrls: ['../../../../app/collection-page/collection-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeIn, + fadeInOut, + ], + standalone: true, + imports: [ + ThemedComcolPageContentComponent, + ErrorComponent, + NgIf, + ThemedLoadingComponent, + TranslateModule, + VarDirective, + AsyncPipe, + ComcolPageHeaderComponent, + ComcolPageLogoComponent, + ThemedComcolPageHandleComponent, + DsoEditMenuComponent, + ThemedComcolPageBrowseByComponent, + ObjectCollectionComponent, + RouterOutlet, + ], }) /** * This component represents a detail page for a single collection */ -export class CollectionPageComponent extends BaseComponent {} +export class CollectionPageComponent extends BaseComponent { +} diff --git a/src/themes/era/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts b/src/themes/era/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts index fb98882b89f..f69fcc868ca 100644 --- a/src/themes/era/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts +++ b/src/themes/era/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts @@ -1,20 +1,33 @@ -import { Component } from '@angular/core'; import { - EditItemTemplatePageComponent as BaseComponent -} from '../../../../../app/collection-page/edit-item-template-page/edit-item-template-page.component'; -import { TranslateModule } from '@ngx-translate/core'; + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { DsoSharedModule } from '../../../../../app/dso-shared/dso-shared.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EditItemTemplatePageComponent as BaseComponent } from '../../../../../app/collection-page/edit-item-template-page/edit-item-template-page.component'; +import { ThemedDsoEditMetadataComponent } from '../../../../../app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; +import { AlertComponent } from '../../../../../app/shared/alert/alert.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; @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', - standalone: true, - imports: [SharedModule, NgIf, DsoSharedModule, RouterLink, AsyncPipe, TranslateModule] + selector: 'ds-themed-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', + standalone: true, + imports: [ + ThemedDsoEditMetadataComponent, + RouterLink, + AsyncPipe, + VarDirective, + NgIf, + TranslateModule, + ThemedLoadingComponent, + AlertComponent, + ], }) /** * Component for editing the item template of a collection diff --git a/src/themes/era/app/community-list-page/community-list-page.component.ts b/src/themes/era/app/community-list-page/community-list-page.component.ts index e35fae26ac4..571964d5de6 100644 --- a/src/themes/era/app/community-list-page/community-list-page.component.ts +++ b/src/themes/era/app/community-list-page/community-list-page.component.ts @@ -1,15 +1,16 @@ import { Component } from '@angular/core'; -import { CommunityListPageComponent as BaseComponent } from '../../../../app/community-list-page/community-list-page.component'; import { TranslateModule } from '@ngx-translate/core'; -import { CommunityListPageModule } from '../../../../app/community-list-page/community-list-page.module'; + +import { ThemedCommunityListComponent } from '../../../../app/community-list-page/community-list/themed-community-list.component'; +import { CommunityListPageComponent as BaseComponent } from '../../../../app/community-list-page/community-list-page.component'; @Component({ - selector: 'ds-community-list-page', - // styleUrls: ['./community-list-page.component.scss'], - // templateUrl: './community-list-page.component.html' - templateUrl: '../../../../app/community-list-page/community-list-page.component.html', - standalone: true, - imports: [CommunityListPageModule, TranslateModule] + selector: 'ds-themed-community-list-page', + // styleUrls: ['./community-list-page.component.scss'], + // templateUrl: './community-list-page.component.html' + templateUrl: '../../../../app/community-list-page/community-list-page.component.html', + standalone: true, + imports: [ThemedCommunityListComponent, TranslateModule], }) /** diff --git a/src/themes/era/app/community-list-page/community-list/community-list.component.ts b/src/themes/era/app/community-list-page/community-list/community-list.component.ts index 11d80f32c18..8a48901634d 100644 --- a/src/themes/era/app/community-list-page/community-list/community-list.component.ts +++ b/src/themes/era/app/community-list-page/community-list/community-list.component.ts @@ -1,10 +1,17 @@ +import { CdkTreeModule } from '@angular/cdk/tree'; +import { + AsyncPipe, + NgClass, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { CommunityListComponent as BaseComponent } from '../../../../../app/community-list-page/community-list/community-list.component'; -import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; -import { CdkTreeModule } from '@angular/cdk/tree'; -import { SharedModule } from '../../../../../app/shared/shared.module'; -import { NgIf, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CommunityListComponent as BaseComponent } from '../../../../../app/community-list-page/community-list/community-list.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { TruncatableComponent } from '../../../../../app/shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../app/shared/truncatable/truncatable-part/truncatable-part.component'; /** * A tree-structured list of nodes representing the communities, their subCommunities and collections. @@ -14,12 +21,12 @@ import { NgIf, AsyncPipe } from '@angular/common'; * 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', - standalone: true, - imports: [NgIf, SharedModule, CdkTreeModule, RouterLink, AsyncPipe, TranslateModule] + selector: 'ds-themed-community-list', + // styleUrls: ['./community-list.component.scss'], + // templateUrl: './community-list.component.html' + templateUrl: '../../../../../app/community-list-page/community-list/community-list.component.html', + standalone: true, + imports: [NgIf, ThemedLoadingComponent, CdkTreeModule, NgClass, RouterLink, TruncatableComponent, TruncatablePartComponent, AsyncPipe, TranslateModule], }) export class CommunityListComponent extends BaseComponent {} diff --git a/src/themes/era/app/community-page/community-page.component.ts b/src/themes/era/app/community-page/community-page.component.ts index 74fec4b1d35..d14507e9051 100644 --- a/src/themes/era/app/community-page/community-page.component.ts +++ b/src/themes/era/app/community-page/community-page.component.ts @@ -1,27 +1,61 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { CommunityPageComponent as BaseComponent} from '../../../../app/community-page/community-page.component'; -import { fadeInOut } from '../../../../app/shared/animations/fade'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { + RouterModule, + RouterOutlet, +} from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { CommunityPageModule } from '../../../../app/community-page/community-page.module'; -import { DsoPageModule } from '../../../../app/shared/dso-page/dso-page.module'; -import { ComcolModule } from '../../../../app/shared/comcol/comcol.module'; -import { StatisticsModule } from '../../../../app/statistics/statistics.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../app/shared/shared.module'; +import { CommunityPageComponent as BaseComponent } from '../../../../app/community-page/community-page.component'; +import { ThemedCollectionPageSubCollectionListComponent } from '../../../../app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component'; +import { ThemedCommunityPageSubCommunityListComponent } from '../../../../app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component'; +import { fadeInOut } from '../../../../app/shared/animations/fade'; +import { ThemedComcolPageBrowseByComponent } from '../../../../app/shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; +import { ThemedComcolPageContentComponent } from '../../../../app/shared/comcol/comcol-page-content/themed-comcol-page-content.component'; +import { ThemedComcolPageHandleComponent } from '../../../../app/shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; +import { ComcolPageHeaderComponent } from '../../../../app/shared/comcol/comcol-page-header/comcol-page-header.component'; +import { ComcolPageLogoComponent } from '../../../../app/shared/comcol/comcol-page-logo/comcol-page-logo.component'; +import { DsoEditMenuComponent } from '../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { ErrorComponent } from '../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component'; +import { VarDirective } from '../../../../app/shared/utils/var.directive'; @Component({ - selector: 'ds-community-page', - templateUrl: './community-page.component.html', - // templateUrl: '../../../../app/community-page/community-page.component.html', - // styleUrls: ['./community-page.component.scss'] - styleUrls: ['../../../../app/community-page/community-page.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut], - standalone: true, - imports: [SharedModule, NgIf, StatisticsModule, ComcolModule, DsoPageModule, CommunityPageModule, AsyncPipe, TranslateModule] + selector: 'ds-themed-community-page', + // templateUrl: './community-page.component.html', + templateUrl: '../../../../app/community-page/community-page.component.html', + // styleUrls: ['./community-page.component.scss'] + styleUrls: ['../../../../app/community-page/community-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [fadeInOut], + standalone: true, + imports: [ + ThemedComcolPageContentComponent, + ErrorComponent, + ThemedLoadingComponent, + NgIf, + TranslateModule, + ThemedCommunityPageSubCommunityListComponent, + ThemedCollectionPageSubCollectionListComponent, + ThemedComcolPageBrowseByComponent, + DsoEditMenuComponent, + ThemedComcolPageHandleComponent, + ComcolPageLogoComponent, + ComcolPageHeaderComponent, + AsyncPipe, + VarDirective, + RouterOutlet, + RouterModule, + ], }) /** * This component represents a detail page for a single community */ -export class CommunityPageComponent extends BaseComponent {} +export class CommunityPageComponent extends BaseComponent { +} diff --git a/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.scss b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts new file mode 100644 index 00000000000..ab2d49b920f --- /dev/null +++ b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts @@ -0,0 +1,32 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CommunityPageSubCollectionListComponent as BaseComponent } from '../../../../../../../app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component'; +import { ErrorComponent } from '../../../../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../../../../app/shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../../../../app/shared/object-collection/object-collection.component'; +import { VarDirective } from '../../../../../../../app/shared/utils/var.directive'; + +@Component({ + selector: 'ds-themed-community-page-sub-collection-list', + // styleUrls: ['./community-page-sub-collection-list.component.scss'], + styleUrls: ['../../../../../../../app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.scss'], + // templateUrl: './community-page-sub-collection-list.component.html', + templateUrl: '../../../../../../../app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html', + imports: [ + ObjectCollectionComponent, + ErrorComponent, + ThemedLoadingComponent, + NgIf, + TranslateModule, + AsyncPipe, + VarDirective, + ], + standalone: true, +}) +export class CommunityPageSubCollectionListComponent extends BaseComponent { +} diff --git a/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.scss b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts new file mode 100644 index 00000000000..3b48efcb0e2 --- /dev/null +++ b/src/themes/era/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts @@ -0,0 +1,36 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CommunityPageSubCommunityListComponent as BaseComponent } from '../../../../../../../app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component'; +import { ErrorComponent } from '../../../../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../../../../app/shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../../../../app/shared/object-collection/object-collection.component'; +import { VarDirective } from '../../../../../../../app/shared/utils/var.directive'; + +@Component({ + selector: 'ds-themed-community-page-sub-community-list', + // styleUrls: ['./community-page-sub-community-list.component.scss'], + styleUrls: ['../../../../../../../app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.scss'], + // templateUrl: './community-page-sub-community-list.component.html', + templateUrl: '../../../../../../../app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html', + standalone: true, + imports: [ + ErrorComponent, + ThemedLoadingComponent, + VarDirective, + NgIf, + ObjectCollectionComponent, + AsyncPipe, + TranslateModule, + ObjectCollectionComponent, + ErrorComponent, + ThemedLoadingComponent, + VarDirective, + ], +}) +export class CommunityPageSubCommunityListComponent extends BaseComponent { +} diff --git a/src/themes/era/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/themes/era/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index 1a8ae0532ec..f74a73a4b88 100644 --- a/src/themes/era/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/themes/era/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -1,19 +1,29 @@ -import { DsoEditMetadataComponent as BaseComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component'; +import { + AsyncPipe, + NgFor, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { LoadingComponent } from '../../shared/loading/loading.component'; -import { SharedModule } from '../../../../../app/shared/shared.module'; -import { DsoSharedModule } from '../../../../../app/dso-shared/dso-shared.module'; -import { NgIf, NgFor, AsyncPipe } from '@angular/common'; + +import { DsoEditMetadataComponent as BaseComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component'; +import { DsoEditMetadataFieldValuesComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component'; +import { DsoEditMetadataHeadersComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component'; +import { DsoEditMetadataValueComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component'; +import { DsoEditMetadataValueHeadersComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component'; +import { MetadataFieldSelectorComponent } from '../../../../../app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component'; +import { AlertComponent } from '../../../../../app/shared/alert/alert.component'; +import { BtnDisabledDirective } from '../../../../../app/shared/btn-disabled.directive'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; @Component({ - selector: 'ds-dso-edit-metadata', - // styleUrls: ['./dso-edit-metadata.component.scss'], - styleUrls: ['../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss'], - // templateUrl: './dso-edit-metadata.component.html', - templateUrl: '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html', - standalone: true, - imports: [NgIf, DsoSharedModule, NgFor, SharedModule, LoadingComponent, AsyncPipe, TranslateModule] + selector: 'ds-themed-dso-edit-metadata', + // styleUrls: ['./dso-edit-metadata.component.scss'], + styleUrls: ['../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss'], + // templateUrl: './dso-edit-metadata.component.html', + templateUrl: '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html', + standalone: true, + imports: [NgIf, DsoEditMetadataHeadersComponent, MetadataFieldSelectorComponent, DsoEditMetadataValueHeadersComponent, DsoEditMetadataValueComponent, NgFor, DsoEditMetadataFieldValuesComponent, AlertComponent, ThemedLoadingComponent, AsyncPipe, TranslateModule, BtnDisabledDirective], }) export class DsoEditMetadataComponent extends BaseComponent { } diff --git a/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index 4dedfa2055b..66803ffd846 100644 --- a/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -1,29 +1,32 @@ -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'; -import { TranslateModule } from '@ngx-translate/core'; + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ItemSharedModule } from '../../../../../../../app/item-page/item-shared.module'; -import { DsoPageModule } from '../../../../../../../app/shared/dso-page/dso-page.module'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; -import { ResultsBackButtonModule } from '../../../../../../../app/shared/results-back-button/results-back-button.module'; -import { NgIf, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { JournalIssueComponent as BaseComponent } from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component'; +import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; +import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; +import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; @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', - standalone: true, - imports: [NgIf, ResultsBackButtonModule, SharedModule, DsoPageModule, ItemSharedModule, RouterLink, AsyncPipe, TranslateModule] + 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', + standalone: true, + imports: [NgIf, ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, GenericItemPageFieldComponent, RelatedItemsComponent, RouterLink, AsyncPipe, TranslateModule], }) /** * The component for displaying metadata and relations of an item of the type Journal Issue diff --git a/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index 21712478004..c63f68bd708 100644 --- a/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/themes/era/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -1,29 +1,32 @@ -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'; -import { TranslateModule } from '@ngx-translate/core'; + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ItemSharedModule } from '../../../../../../../app/item-page/item-shared.module'; -import { DsoPageModule } from '../../../../../../../app/shared/dso-page/dso-page.module'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; -import { ResultsBackButtonModule } from '../../../../../../../app/shared/results-back-button/results-back-button.module'; -import { NgIf, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { JournalVolumeComponent as BaseComponent } from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component'; +import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; +import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; +import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; @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', - standalone: true, - imports: [NgIf, ResultsBackButtonModule, SharedModule, DsoPageModule, ItemSharedModule, RouterLink, AsyncPipe, TranslateModule] + 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', + standalone: true, + imports: [NgIf, ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, GenericItemPageFieldComponent, RelatedItemsComponent, RouterLink, AsyncPipe, TranslateModule], }) /** * The component for displaying metadata and relations of an item of the type Journal Volume diff --git a/src/themes/era/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/themes/era/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index a3069813935..104f1b40b05 100644 --- a/src/themes/era/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/themes/era/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -1,29 +1,33 @@ -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'; -import { TranslateModule } from '@ngx-translate/core'; + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ItemSharedModule } from '../../../../../../../app/item-page/item-shared.module'; -import { DsoPageModule } from '../../../../../../../app/shared/dso-page/dso-page.module'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; -import { ResultsBackButtonModule } from '../../../../../../../app/shared/results-back-button/results-back-button.module'; -import { NgIf, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { JournalComponent as BaseComponent } from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component'; +import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { TabbedRelatedEntitiesSearchComponent } from '../../../../../../../app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; +import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; +import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; +import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; @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', - standalone: true, - imports: [NgIf, ResultsBackButtonModule, SharedModule, DsoPageModule, ItemSharedModule, RouterLink, AsyncPipe, TranslateModule] + 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', + standalone: true, + imports: [NgIf, ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, GenericItemPageFieldComponent, RelatedItemsComponent, RouterLink, TabbedRelatedEntitiesSearchComponent, AsyncPipe, TranslateModule], }) /** * The component for displaying metadata and relations of an item of the type Journal diff --git a/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.scss b/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.ts new file mode 100644 index 00000000000..f1a60957eda --- /dev/null +++ b/src/themes/era/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -0,0 +1,33 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { GenericItemPageFieldComponent } from 'src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from 'src/app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { TabbedRelatedEntitiesSearchComponent } from 'src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; +import { RelatedItemsComponent } from 'src/app/item-page/simple/related-items/related-items-component'; +import { DsoEditMenuComponent } from 'src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { MetadataFieldWrapperComponent } from 'src/app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { ThemedResultsBackButtonComponent } from 'src/app/shared/results-back-button/themed-results-back-button.component'; +import { ThemedThumbnailComponent } from 'src/app/thumbnail/themed-thumbnail.component'; + +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { PersonComponent as BaseComponent } from '../../../../../../../app/entity-groups/research-entities/item-pages/person/person.component'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; + +@listableObjectComponent('Person', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-person', + // styleUrls: ['./person.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/research-entities/item-pages/person/person.component.scss'], + // templateUrl: './person.component.html', + templateUrl: '../../../../../../../app/entity-groups/research-entities/item-pages/person/person.component.html', + standalone: true, + imports: [NgIf, ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, GenericItemPageFieldComponent, RelatedItemsComponent, RouterLink, TabbedRelatedEntitiesSearchComponent, AsyncPipe, TranslateModule], +}) +export class PersonComponent extends BaseComponent { +} diff --git a/src/themes/era/app/footer/footer.component.html b/src/themes/era/app/footer/footer.component.html index f4b376a2fd0..ad0842e5608 100644 --- a/src/themes/era/app/footer/footer.component.html +++ b/src/themes/era/app/footer/footer.component.html @@ -5,7 +5,7 @@
- + Univeristy of Edinburgh Library Branding Baton
diff --git a/src/themes/era/app/footer/footer.component.ts b/src/themes/era/app/footer/footer.component.ts index d6802eb91ce..f3a29fafc5c 100644 --- a/src/themes/era/app/footer/footer.component.ts +++ b/src/themes/era/app/footer/footer.component.ts @@ -1,15 +1,23 @@ +import { + AsyncPipe, + DatePipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { FooterComponent as BaseComponent } from '../../../../app/footer/footer.component'; @Component({ - selector: 'ds-footer', - styleUrls: ['./footer.component.scss'], - // styleUrls: ['../../../../app/footer/footer.component.scss'], - templateUrl: './footer.component.html' - // templateUrl: '../../../../app/footer/footer.component.html' - , - standalone: true + selector: 'ds-themed-footer', + styleUrls: ['./footer.component.scss'], + // styleUrls: ['../../../../app/footer/footer.component.scss'], + templateUrl: './footer.component.html', + // templateUrl: '../../../../app/footer/footer.component.html', + standalone: true, + imports: [NgIf, RouterLink, AsyncPipe, DatePipe, TranslateModule], }) export class FooterComponent extends BaseComponent { - currentYear: number = new Date().getFullYear(); + currentYear: number = new Date().getFullYear(); } diff --git a/src/themes/era/app/forbidden/forbidden.component.ts b/src/themes/era/app/forbidden/forbidden.component.ts index 7bba751d029..58c32581c1e 100644 --- a/src/themes/era/app/forbidden/forbidden.component.ts +++ b/src/themes/era/app/forbidden/forbidden.component.ts @@ -1,17 +1,18 @@ import { Component } from '@angular/core'; -import { ForbiddenComponent as BaseComponent } from '../../../../app/forbidden/forbidden.component'; -import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ForbiddenComponent as BaseComponent } from '../../../../app/forbidden/forbidden.component'; @Component({ - selector: 'ds-forbidden', - // templateUrl: './forbidden.component.html', - templateUrl: '../../../../app/forbidden/forbidden.component.html', - // styleUrls: ['./forbidden.component.scss'] - styleUrls: ['../../../../app/forbidden/forbidden.component.scss'], - standalone: true, - imports: [RouterLink, TranslateModule] + selector: 'ds-themed-forbidden', + // templateUrl: './forbidden.component.html', + templateUrl: '../../../../app/forbidden/forbidden.component.html', + // styleUrls: ['./forbidden.component.scss'] + styleUrls: ['../../../../app/forbidden/forbidden.component.scss'], + standalone: true, + imports: [RouterLink, TranslateModule], }) /** * This component representing the `Forbidden` DSpace page. diff --git a/src/themes/era/app/forgot-password/forgot-password-email/forgot-email.component.ts b/src/themes/era/app/forgot-password/forgot-password-email/forgot-email.component.ts index bf8506cec6b..ac334902a33 100644 --- a/src/themes/era/app/forgot-password/forgot-password-email/forgot-email.component.ts +++ b/src/themes/era/app/forgot-password/forgot-password-email/forgot-email.component.ts @@ -1,15 +1,18 @@ import { Component } from '@angular/core'; +import { ThemedRegisterEmailFormComponent } from 'src/app/register-email-form/themed-registry-email-form.component'; + import { ForgotEmailComponent as BaseComponent } from '../../../../../app/forgot-password/forgot-password-email/forgot-email.component'; -import { RegisterEmailFormModule } from '../../../../../app/register-email-form/register-email-form.module'; @Component({ - selector: 'ds-forgot-email', - // styleUrls: ['./forgot-email.component.scss'], - styleUrls: ['../../../../../app/forgot-password/forgot-password-email/forgot-email.component.scss'], - // templateUrl: './forgot-email.component.html' - templateUrl: '../../../../../app/forgot-password/forgot-password-email/forgot-email.component.html', - standalone: true, - imports: [RegisterEmailFormModule] + selector: 'ds-themed-forgot-email', + // styleUrls: ['./forgot-email.component.scss'], + styleUrls: ['../../../../../app/forgot-password/forgot-password-email/forgot-email.component.scss'], + // templateUrl: './forgot-email.component.html' + templateUrl: '../../../../../app/forgot-password/forgot-password-email/forgot-email.component.html', + standalone: true, + imports: [ + ThemedRegisterEmailFormComponent, + ], }) /** * Component responsible the forgot password email step diff --git a/src/themes/era/app/forgot-password/forgot-password-form/forgot-password-form.component.ts b/src/themes/era/app/forgot-password/forgot-password-form/forgot-password-form.component.ts index 7cc341dcb49..37fb3834294 100644 --- a/src/themes/era/app/forgot-password/forgot-password-form/forgot-password-form.component.ts +++ b/src/themes/era/app/forgot-password/forgot-password-form/forgot-password-form.component.ts @@ -1,17 +1,30 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { ForgotPasswordFormComponent as BaseComponent } from '../../../../../app/forgot-password/forgot-password-form/forgot-password-form.component'; import { TranslateModule } from '@ngx-translate/core'; -import { ProfilePageModule } from '../../../../../app/profile-page/profile-page.module'; -import { NgIf, AsyncPipe } from '@angular/common'; + +import { ForgotPasswordFormComponent as BaseComponent } from '../../../../../app/forgot-password/forgot-password-form/forgot-password-form.component'; +import { ProfilePageSecurityFormComponent } from '../../../../../app/profile-page/profile-page-security-form/profile-page-security-form.component'; +import { BtnDisabledDirective } from '../../../../../app/shared/btn-disabled.directive'; +import { BrowserOnlyPipe } from '../../../../../app/shared/utils/browser-only.pipe'; @Component({ - selector: 'ds-forgot-password-form', - // styleUrls: ['./forgot-password-form.component.scss'], - styleUrls: ['../../../../../app/forgot-password/forgot-password-form/forgot-password-form.component.scss'], - // templateUrl: './forgot-password-form.component.html' - templateUrl: '../../../../../app/forgot-password/forgot-password-form/forgot-password-form.component.html', - standalone: true, - imports: [NgIf, ProfilePageModule, AsyncPipe, TranslateModule] + selector: 'ds-themed-forgot-password-form', + // styleUrls: ['./forgot-password-form.component.scss'], + styleUrls: ['../../../../../app/forgot-password/forgot-password-form/forgot-password-form.component.scss'], + // templateUrl: './forgot-password-form.component.html' + templateUrl: '../../../../../app/forgot-password/forgot-password-form/forgot-password-form.component.html', + standalone: true, + imports: [ + TranslateModule, + BrowserOnlyPipe, + ProfilePageSecurityFormComponent, + AsyncPipe, + NgIf, + BtnDisabledDirective, + ], }) /** * Component for a user to enter a new password for a forgot token. diff --git a/src/themes/era/app/header-nav-wrapper/header-navbar-wrapper.component.ts b/src/themes/era/app/header-nav-wrapper/header-navbar-wrapper.component.ts index 9d51fb63e8a..ac1ac94140f 100644 --- a/src/themes/era/app/header-nav-wrapper/header-navbar-wrapper.component.ts +++ b/src/themes/era/app/header-nav-wrapper/header-navbar-wrapper.component.ts @@ -1,20 +1,24 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; import { Component } from '@angular/core'; + +import { ThemedHeaderComponent } from '../../../../app/header/themed-header.component'; import { HeaderNavbarWrapperComponent as BaseComponent } from '../../../../app/header-nav-wrapper/header-navbar-wrapper.component'; -import { NavbarModule } from '../../../../app/navbar/navbar.module'; -import { RootModule } from '../../../../app/root.module'; -import { NgClass, AsyncPipe } from '@angular/common'; +import { ThemedNavbarComponent } from '../../../../app/navbar/themed-navbar.component'; /** * This component represents a wrapper for the horizontal navbar and the header */ @Component({ - selector: 'ds-header-navbar-wrapper', - // styleUrls: ['./header-navbar-wrapper.component.scss'], - styleUrls: ['../../../../app/header-nav-wrapper/header-navbar-wrapper.component.scss'], - // templateUrl: './header-navbar-wrapper.component.html', - templateUrl: '../../../../app/header-nav-wrapper/header-navbar-wrapper.component.html', - standalone: true, - imports: [NgClass, RootModule, NavbarModule, AsyncPipe] + selector: 'ds-themed-header-navbar-wrapper', + // styleUrls: ['./header-navbar-wrapper.component.scss'], + styleUrls: ['../../../../app/header-nav-wrapper/header-navbar-wrapper.component.scss'], + // templateUrl: './header-navbar-wrapper.component.html', + templateUrl: '../../../../app/header-nav-wrapper/header-navbar-wrapper.component.html', + standalone: true, + imports: [NgClass, ThemedHeaderComponent, ThemedNavbarComponent, AsyncPipe], }) export class HeaderNavbarWrapperComponent extends BaseComponent { } diff --git a/src/themes/era/app/header/header.component.html b/src/themes/era/app/header/header.component.html index f94e150af7d..e6077df3380 100644 --- a/src/themes/era/app/header/header.component.html +++ b/src/themes/era/app/header/header.component.html @@ -10,15 +10,45 @@
- - + +

Edinburgh Research Archive

+
+ +
+
+
+
- + \ No newline at end of file diff --git a/src/themes/era/app/header/header.component.scss b/src/themes/era/app/header/header.component.scss index 7b1f6cb416f..693127e4243 100644 --- a/src/themes/era/app/header/header.component.scss +++ b/src/themes/era/app/header/header.component.scss @@ -48,6 +48,8 @@ a:hover { } .era-logo { max-width: 12rem!important; + width: 100%; + height: auto; } a.navbar-brand.my-2 { margin: 0; @@ -63,4 +65,7 @@ a.navbar-brand.my-2 { max-width: 20.75rem; height: auto; max-height: 4.9375rem; +} +.mobile-menu { + padding: 7rem 0rem 3rem 0rem; } \ No newline at end of file diff --git a/src/themes/era/app/header/header.component.ts b/src/themes/era/app/header/header.component.ts index e82296f5c35..94f6ce34584 100644 --- a/src/themes/era/app/header/header.component.ts +++ b/src/themes/era/app/header/header.component.ts @@ -1,18 +1,30 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { HeaderComponent as BaseComponent } from '../../../../app/header/header.component'; -import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { ThemedLangSwitchComponent } from 'src/app/shared/lang-switch/themed-lang-switch.component'; + +import { ContextHelpToggleComponent } from '../../../../app/header/context-help-toggle/context-help-toggle.component'; +import { HeaderComponent as BaseComponent } from '../../../../app/header/header.component'; +import { ThemedSearchNavbarComponent } from '../../../../app/search-navbar/themed-search-navbar.component'; +import { ThemedAuthNavMenuComponent } from '../../../../app/shared/auth-nav-menu/themed-auth-nav-menu.component'; +import { ImpersonateNavbarComponent } from '../../../../app/shared/impersonate-navbar/impersonate-navbar.component'; /** * Represents the header with the logo and simple navigation */ @Component({ - selector: 'ds-header', - styleUrls: ['header.component.scss'], - // styleUrls: ['../../../../app/header/header.component.scss'], - templateUrl: 'header.component.html', - standalone: true, - imports: [RouterLink, TranslateModule] + selector: 'ds-themed-header', + styleUrls: ['header.component.scss'], + // styleUrls: ['../../../../app/header/header.component.scss'], + templateUrl: 'header.component.html', + // templateUrl: '../../../../app/header/header.component.html', + standalone: true, + imports: [RouterLink, ThemedLangSwitchComponent, NgbDropdownModule, ThemedSearchNavbarComponent, ContextHelpToggleComponent, ThemedAuthNavMenuComponent, ImpersonateNavbarComponent, TranslateModule, AsyncPipe, NgIf], }) export class HeaderComponent extends BaseComponent { } diff --git a/src/themes/era/app/home-page/home-news/home-news.component.ts b/src/themes/era/app/home-page/home-news/home-news.component.ts index 1fb9954741e..3574c8f7470 100644 --- a/src/themes/era/app/home-page/home-news/home-news.component.ts +++ b/src/themes/era/app/home-page/home-news/home-news.component.ts @@ -1,14 +1,14 @@ import { Component } from '@angular/core'; + import { HomeNewsComponent as BaseComponent } from '../../../../../app/home-page/home-news/home-news.component'; @Component({ - selector: 'ds-home-news', - styleUrls: ['./home-news.component.scss'], - // styleUrls: ['../../../../../app/home-page/home-news/home-news.component.scss'], - templateUrl: './home-news.component.html' - // templateUrl: '../../../../../app/home-page/home-news/home-news.component.html' - , - standalone: true + selector: 'ds-themed-home-news', + styleUrls: ['./home-news.component.scss'], + // styleUrls: ['../../../../../app/home-page/home-news/home-news.component.scss'], + templateUrl: './home-news.component.html', + // templateUrl: '../../../../../app/home-page/home-news/home-news.component.html', + standalone: true, }) /** diff --git a/src/themes/era/app/home-page/home-page.component.html b/src/themes/era/app/home-page/home-page.component.html index 8b7f3f63f5d..9a1b23d9c69 100644 --- a/src/themes/era/app/home-page/home-page.component.html +++ b/src/themes/era/app/home-page/home-page.component.html @@ -1,9 +1,22 @@ - -
- - - - + + + + + +
+
+ + + + + + + + diff --git a/src/themes/era/app/home-page/home-page.component.ts b/src/themes/era/app/home-page/home-page.component.ts index 2e805071130..fb74ebb7ee8 100644 --- a/src/themes/era/app/home-page/home-page.component.ts +++ b/src/themes/era/app/home-page/home-page.component.ts @@ -1,19 +1,30 @@ +import { + AsyncPipe, + NgClass, + NgIf, + NgTemplateOutlet, +} from '@angular/common'; import { Component } from '@angular/core'; -import { HomePageComponent as BaseComponent } from '../../../../app/home-page/home-page.component'; import { TranslateModule } from '@ngx-translate/core'; -import { NgIf } from '@angular/common'; -import { SharedModule } from '../../../../app/shared/shared.module'; -import { HomePageModule } from '../../../../app/home-page/home-page.module'; + +import { HomeCoarComponent } from '../../../../app/home-page/home-coar/home-coar.component'; +import { ThemedHomeNewsComponent } from '../../../../app/home-page/home-news/themed-home-news.component'; +import { HomePageComponent as BaseComponent } from '../../../../app/home-page/home-page.component'; +import { RecentItemListComponent } from '../../../../app/home-page/recent-item-list/recent-item-list.component'; +import { ThemedTopLevelCommunityListComponent } from '../../../../app/home-page/top-level-community-list/themed-top-level-community-list.component'; +import { SuggestionsPopupComponent } from '../../../../app/notifications/suggestions-popup/suggestions-popup.component'; +import { ThemedConfigurationSearchPageComponent } from '../../../../app/search-page/themed-configuration-search-page.component'; +import { ThemedSearchFormComponent } from '../../../../app/shared/search-form/themed-search-form.component'; +import { PageWithSidebarComponent } from '../../../../app/shared/sidebar/page-with-sidebar.component'; @Component({ - selector: 'ds-home-page', - styleUrls: ['./home-page.component.scss'], - // styleUrls: ['../../../../app/home-page/home-page.component.scss'], - templateUrl: './home-page.component.html' - // templateUrl: '../../../../app/home-page/home-page.component.html' - , - standalone: true, - imports: [HomePageModule, SharedModule, NgIf, TranslateModule] + selector: 'ds-themed-home-page', + styleUrls: ['./home-page.component.scss'], + // styleUrls: ['../../../../app/home-page/home-page.component.scss'], + templateUrl: './home-page.component.html', + // templateUrl: '../../../../app/home-page/home-page.component.html', + standalone: true, + imports: [ThemedHomeNewsComponent, NgTemplateOutlet, NgIf, ThemedSearchFormComponent, ThemedTopLevelCommunityListComponent, RecentItemListComponent, AsyncPipe, TranslateModule, NgClass, SuggestionsPopupComponent, ThemedConfigurationSearchPageComponent, PageWithSidebarComponent, HomeCoarComponent], }) export class HomePageComponent extends BaseComponent { diff --git a/src/themes/era/app/home-page/recent-item-list/recent-item-list.component.ts b/src/themes/era/app/home-page/recent-item-list/recent-item-list.component.ts index 4a2fe00ab06..ef7b499e754 100644 --- a/src/themes/era/app/home-page/recent-item-list/recent-item-list.component.ts +++ b/src/themes/era/app/home-page/recent-item-list/recent-item-list.component.ts @@ -1,17 +1,25 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { RecentItemListComponent as BaseComponent } from '../../../../../app/home-page/recent-item-list/recent-item-list.component'; import { TranslateModule } from '@ngx-translate/core'; -import { NgIf, NgClass, NgFor, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; + +import { RecentItemListComponent as BaseComponent } from '../../../../../app/home-page/recent-item-list/recent-item-list.component'; +import { ErrorComponent } from '../../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../../app/shared/object-collection/object-collection.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; + @Component({ - selector: 'ds-recent-item-list', - // styleUrls: ['./recent-item-list.component.scss'], - styleUrls: ['../../../../../app/home-page/recent-item-list/recent-item-list.component.scss'], - templateUrl: './recent-item-list.component.html' - // templateUrl: '../../../../../app/home-page/recent-item-list/ds-recent-item-list.component.html' - , - standalone: true, - imports: [SharedModule, NgIf, NgClass, NgFor, AsyncPipe, TranslateModule] + selector: 'ds-themed-recent-item-list', + styleUrls: ['./recent-item-list.component.scss'], + // styleUrls: ['../../../../../app/home-page/recent-item-list/recent-item-list.component.scss'], + templateUrl: './recent-item-list.component.html', + // templateUrl: '../../../../../app/home-page/recent-item-list/recent-item-list.component.html', + standalone: true, + imports: [VarDirective, NgIf, ObjectCollectionComponent, ErrorComponent, ThemedLoadingComponent, AsyncPipe, TranslateModule], }) -export class RecentItemListComponent extends BaseComponent {} \ No newline at end of file +export class RecentItemListComponent extends BaseComponent {} + diff --git a/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.html b/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.html index e520c0da33f..d258b40eb46 100644 --- a/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.html +++ b/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.html @@ -1,16 +1,11 @@ -
- - - -
- - -
\ No newline at end of file +
+ + +
+ + diff --git a/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.ts b/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.ts index 188e8088134..9593e0b10bf 100644 --- a/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.ts +++ b/src/themes/era/app/home-page/top-level-community-list/top-level-community-list.component.ts @@ -1,18 +1,24 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { TopLevelCommunityListComponent as BaseComponent } from '../../../../../app/home-page/top-level-community-list/top-level-community-list.component'; import { TranslateModule } from '@ngx-translate/core'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; + +import { TopLevelCommunityListComponent as BaseComponent } from '../../../../../app/home-page/top-level-community-list/top-level-community-list.component'; +import { ErrorComponent } from '../../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../../app/shared/object-collection/object-collection.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; @Component({ - selector: 'ds-top-level-community-list', - styleUrls: ['./top-level-community-list.component.scss'], - // styleUrls: ['../../../../../app/home-page/top-level-community-list/top-level-community-list.component.scss'], - templateUrl: './top-level-community-list.component.html' - // templateUrl: '../../../../../app/home-page/top-level-community-list/top-level-community-list.component.html' - , - standalone: true, - imports: [SharedModule, NgIf, AsyncPipe, TranslateModule] + selector: 'ds-themed-top-level-community-list', + // styleUrls: ['./top-level-community-list.component.scss'], + styleUrls: ['../../../../../app/home-page/top-level-community-list/top-level-community-list.component.scss'], + templateUrl: './top-level-community-list.component.html', + // templateUrl: '../../../../../app/home-page/top-level-community-list/top-level-community-list.component.html', + standalone: true, + imports: [VarDirective, NgIf, ObjectCollectionComponent, ErrorComponent, ThemedLoadingComponent, AsyncPipe, TranslateModule], }) export class TopLevelCommunityListComponent extends BaseComponent {} diff --git a/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.html b/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.html new file mode 100644 index 00000000000..85e63cc1fbd --- /dev/null +++ b/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.html @@ -0,0 +1,232 @@ +
+

Accessibility statement for the Edinburgh Research Archive +

+

+ Website accessibility statement in line with Public Sector Body (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 +

+

This accessibility statement applies to the Edinburgh Research Archive - https://era.ed.ac.uk/ +

+

This website is maintained by the Digital Library team, Library and University Collections, the University of Edinburgh on behalf of Queen Margaret University. We want as many people as possible to be able to use this application. For example, that means you should be able to:

+
    +
  • Using your browser settings, change colours, contrast levels and fonts
  • +
  • zoom in up to 200% without the text spilling off the screen
  • +
  • navigate most of the website using just a keyboard
  • +
  • navigate most of the website using speech recognition software such as Dragon Naturally Speaking
  • +
  • listen to most of the website using a screen reader (including the most recent versions of Job Access with Speech (JAWS))
  • +
  • Experience no time limits when using the site
  • +
  • There is no flashing, scrolling or moving text
  • +
+

We've also made the website text as simple as possible to understand.

+

Customising the website

+

AbilityNet has advice on making your device easier to use if you have a disability. This is an external site with suggestions to make your computer more accessible:

+

+ AbilityNet - My computer my way +

+

With a few simple steps you can customise the appearance of our website using your browser settings to make it easier to read and navigate:

+

+ Additional information on how to customise our website appearance +

+

If you are a member of University staff or a student, you can use the free SensusAccess accessible document conversion service:

+

+ SenusAccess Information +

+

How accessible this website is

+

We know some parts of this website are not fully accessible:

+
    +
  • The website is not fully compatible with Dragon Naturally Speaking on all browsers;
  • +
  • It is not possible to tab through all the content on all browsers;
  • +
  • Tab highlighting can obscure the actual content;
  • +
  • Data entry and validation is not fully robust;
  • +
  • Not all hyperlinks are formatted correctly;
  • +
  • Not all colour contrasts meet the Web Content Accessibility Guidelines (WCAG) 2.1 AA standard;
  • +
  • Not all non-text content has appropriate alternative text;
  • +
  • No 'skip to main content' button is present throughout the website;
  • +
  • The website is not fully compatible with mobile accessibility functionality (Android, iOS);
  • +
  • Some PDF documents are not fully accessible;
  • +
  • Not all touch targets are a minimum of 9mm by 9mm.
  • +
+

Feedback and contact information

+

If you need information on this website in a different format, including accessible PDF, large print, audio recording or braille please contact:

+

Email: scholcomms@ed.ac.uk +

+

Phone: +44 (0)131 651 5226

+

British Sign Language (BSL) users can contact us via Contact Scotland BSL, the on-line BSL interpreting service

+

+ Contact Scotland BSL +

+

We'll consider your request and get back to you in 5 working days.

+

Reporting accessibility problems with this website

+

We are always looking to improve the accessibility of this website. If you find any problems not listed on this page, or think we're not meeting accessibility requirements, please contact:

+

Email: scholcomms@ed.ac.uk +

+

Phone: +44 (0)131 651 5226

+

British Sign Language (BSL) users can contact us via Contact Scotland BSL, the on-line BSL interpreting service

+

+ Contact Scotland BSL +

+

We'll consider your request and get back to you in 5 working days.

+

Enforcement procedure

+

The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 (the 'accessibility regulations'). If you're not happy with how we respond to your complaint please contact the Equality Advisory and Support Service (EASS) directly:

+

+ Contact details for the Equality Advisory and Support Service (EASS) +

+

The government has produced information on how to report accessibility issues:

+

+ Reporting an accessibility problem on a public sector website +

+

Contacting us by phone using British Sign Language

+

British Sign Language service Contact Scotland BSL runs a service for British Sign Language users and all of Scotland's public bodies using video relay. This enables sign language users to contact public bodies and vice versa. The service operates 24 hours a day, 7 days a week.

+

+ British Sign Language Scotland service details +

+

Technical information about this website's accessibility

+

The University of Edinburgh is committed to making its websites and applications accessible, in accordance with the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018.

+

This website is partially compliant with the Web Content Accessibility Guidelines (WCAG) 2.1 AA standard, due to the non-compliances listed below.

+

The full guidelines are available at

+

+ Web Content Accessibility Guidelines (WCAG) 2.1 AA standard +

+

Non accessible content

+

The content listed below is non-accessible for the following reasons.

+

Noncompliance with the accessibility regulations.

+

The following items to not comply with the WCAG 2.1 AA success criteria:

+ +
    +
  • There may not be sufficient colour contrast between font and background colours, there are issues where text size is very small
  • + +
+ +
    +
  • Not all the content reflows when the page is magnified above 200%
  • + +
+ +
    +
  • There is no 'skip to main content' option available throughout the website
  • + +
+ + + + + + +

Unless specified otherwise, a complete solution, or significant improvement, will be in place by December 2023. At this time we believe all items are within our control.

+

Disproportionate burden

+

At this time, we are not claiming any disproportionate burden.

+

Content that is not within the Scope of the Accessibility Regulations

+

At this time, we do not believe that any content is outside the scope of the accessibility regulations.

+

What we're doing to improve accessibility

+

We will continue to address and make adequate improvements to the accessibility issues highlighted. Unless specified otherwise, a complete solution or significant improvement will be in place by December 2023.

+

While we are in the process of resolving these accessibility issues we will ensure reasonable adjustments are in place to make sure no user is disadvantaged. As changes are made, we will continue to review accessibility and retest the accessibility of this website.

+

We are planning to upgrade the site to the most recent release of the system architecture before the end of 2023 which includes improvements to the current accessibility requirements. During this upgrade improving the other accessibility issues highlighted will be a key component of the development process.

+

Preparation of this accessibility statement

+

This statement was first prepared on 12th October 2019. It was last reviewed on 13th December 2022.

+

This website was first tested on 12th October 2019 and last tested on 4th July 2022. The test was carried out by The University Library and University Collections Digital Library Development team using the automated Wave WEBAIM and Little Forest testing tools. The website is scheduled for manual testing by July 2023.

+

This website was last tested by the Library and University Collections Digital Library team, University of Edinburgh in July 2022 following on from previous automated testing of the system the previous year. This was primarily using the Google Chrome (100.0.4896.127), Mozilla Firefox (91.8.0esr), Internet Explorer (11.0) and Microsoft Edge (100.0.1185.39) browsers for comparative purposes.

+

Recent world-wide usage levels survey for different screen readers and browsers shows that Chrome, Mozilla Firefox and Microsoft Edge are increasing in popularity and Google Chrome is now the favoured browser for screen readers:

+

+ WebAIM: Screen Reader User Survey +

+

The aforementioned three browsers have been used in certain questions for reasons of breadth and variety.

+

We ran automated testing using Wave WEBAIM and then manual testing that included:

+
    +
  • Spell check functionality;
  • +
  • Scaling using different resolutions and reflow;
  • +
  • Options to customise the interface (magnification, font, background colour, etc);
  • +
  • Keyboard navigation and keyboard traps;
  • +
  • Data validation;
  • +
  • Warning of links opening in new tab or window;
  • +
  • Information conveyed in the colour or sound only;
  • +
  • Flashing, moving or scrolling text;
  • +
  • Operability if JavaScript is disabled;
  • +
  • Use with screen reading software (for example JAWS);
  • +
  • Assistive software (TextHelp Read and Write, Windows Magnifier, ZoomText, Dragon Naturally Speaking, TalkBack and VoiceOver);
  • +
  • Tooltips and text alternatives for any non-text content;
  • +
  • Time limits;
  • +
  • Compatibility with mobile accessibility functionality (Android and iOS).
  • +
+

Change Log

+

Since our first evaluation and statement which was based on automated testing we have been doing extensive manual testing including with a range of assistive technology to ensure we have a clear picture of the accessibility issues and how best to resolve them.

+
\ No newline at end of file diff --git a/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.scss b/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.ts b/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.ts new file mode 100644 index 00000000000..61ec5403614 --- /dev/null +++ b/src/themes/era/app/info/accessibility-settings/accessibility-settings.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +import { AccessibilitySettingsComponent as BaseComponent } from '../../../../../app/info/accessibility-settings/accessibility-settings.component'; +import { ThemedAccessibilitySettingsComponent } from '../../../../../app/info/accessibility-settings/themed-accessibility-settings.component'; + +@Component({ + selector: 'ds-themed-accessibility-settings', + // styleUrls: ['./privacy.component.scss'], + styleUrls: ['./accessibility-settings.component.scss'], + // templateUrl: './privacy.component.html' + templateUrl: './accessibility-settings.component.html', + standalone: true, + imports: [ThemedAccessibilitySettingsComponent], +}) + +/** + * Component displaying the Accessibility Settings + */ +export class AccessibilitySettingsComponent extends BaseComponent {} diff --git a/src/themes/era/app/info/end-user-agreement/end-user-agreement.component.ts b/src/themes/era/app/info/end-user-agreement/end-user-agreement.component.ts index f7410e0d41c..2b78006f650 100644 --- a/src/themes/era/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/themes/era/app/info/end-user-agreement/end-user-agreement.component.ts @@ -1,17 +1,19 @@ import { Component } from '@angular/core'; -import { EndUserAgreementComponent as BaseComponent } from '../../../../../app/info/end-user-agreement/end-user-agreement.component'; -import { TranslateModule } from '@ngx-translate/core'; import { FormsModule } from '@angular/forms'; -import { InfoModule } from '../../../../../app/info/info.module'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EndUserAgreementComponent as BaseComponent } from '../../../../../app/info/end-user-agreement/end-user-agreement.component'; +import { EndUserAgreementContentComponent } from '../../../../../app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component'; +import { BtnDisabledDirective } from '../../../../../app/shared/btn-disabled.directive'; @Component({ - 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' - templateUrl: '../../../../../app/info/end-user-agreement/end-user-agreement.component.html', - standalone: true, - imports: [InfoModule, FormsModule, TranslateModule] + selector: 'ds-themed-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' + templateUrl: '../../../../../app/info/end-user-agreement/end-user-agreement.component.html', + standalone: true, + imports: [EndUserAgreementContentComponent, FormsModule, TranslateModule, BtnDisabledDirective], }) /** diff --git a/src/themes/era/app/info/feedback/feedback-form/feedback-form.component.ts b/src/themes/era/app/info/feedback/feedback-form/feedback-form.component.ts index f2193460d80..38356671118 100644 --- a/src/themes/era/app/info/feedback/feedback-form/feedback-form.component.ts +++ b/src/themes/era/app/info/feedback/feedback-form/feedback-form.component.ts @@ -1,20 +1,23 @@ +import { NgIf } from '@angular/common'; import { Component } from '@angular/core'; import { - FeedbackFormComponent as BaseComponent -} from '../../../../../../app/info/feedback/feedback-form/feedback-form.component'; + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../../../../app/shared/shared.module'; -import { NgIf } from '@angular/common'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { FeedbackFormComponent as BaseComponent } from '../../../../../../app/info/feedback/feedback-form/feedback-form.component'; +import { BtnDisabledDirective } from '../../../../../../app/shared/btn-disabled.directive'; +import { ErrorComponent } from '../../../../../../app/shared/error/error.component'; @Component({ - selector: 'ds-feedback-form', - // templateUrl: './feedback-form.component.html', - templateUrl: '../../../../../../app/info/feedback/feedback-form/feedback-form.component.html', - // styleUrls: ['./feedback-form.component.scss'], - styleUrls: ['../../../../../../app/info/feedback/feedback-form/feedback-form.component.scss'], - standalone: true, - imports: [FormsModule, ReactiveFormsModule, NgIf, SharedModule, TranslateModule] + selector: 'ds-themed-feedback-form', + // templateUrl: './feedback-form.component.html', + templateUrl: '../../../../../../app/info/feedback/feedback-form/feedback-form.component.html', + // styleUrls: ['./feedback-form.component.scss'], + styleUrls: ['../../../../../../app/info/feedback/feedback-form/feedback-form.component.scss'], + standalone: true, + imports: [FormsModule, ReactiveFormsModule, NgIf, ErrorComponent, TranslateModule, BtnDisabledDirective], }) export class FeedbackFormComponent extends BaseComponent { } diff --git a/src/themes/era/app/info/feedback/feedback.component.ts b/src/themes/era/app/info/feedback/feedback.component.ts index 19df0f17ba1..e31cfd46cf9 100644 --- a/src/themes/era/app/info/feedback/feedback.component.ts +++ b/src/themes/era/app/info/feedback/feedback.component.ts @@ -1,15 +1,16 @@ import { Component } from '@angular/core'; + import { FeedbackComponent as BaseComponent } from '../../../../../app/info/feedback/feedback.component'; -import { InfoModule } from '../../../../../app/info/info.module'; +import { ThemedFeedbackFormComponent } from '../../../../../app/info/feedback/feedback-form/themed-feedback-form.component'; @Component({ - selector: 'ds-feedback', - // styleUrls: ['./feedback.component.scss'], - styleUrls: ['../../../../../app/info/feedback/feedback.component.scss'], - // templateUrl: './feedback.component.html' - templateUrl: '../../../../../app/info/feedback/feedback.component.html', - standalone: true, - imports: [InfoModule] + selector: 'ds-themed-feedback', + // styleUrls: ['./feedback.component.scss'], + styleUrls: ['../../../../../app/info/feedback/feedback.component.scss'], + // templateUrl: './feedback.component.html' + templateUrl: '../../../../../app/info/feedback/feedback.component.html', + standalone: true, + imports: [ThemedFeedbackFormComponent], }) /** diff --git a/src/themes/era/app/info/privacy/privacy.component.ts b/src/themes/era/app/info/privacy/privacy.component.ts index e9a6b2c85bc..463a89a0925 100644 --- a/src/themes/era/app/info/privacy/privacy.component.ts +++ b/src/themes/era/app/info/privacy/privacy.component.ts @@ -1,15 +1,16 @@ import { Component } from '@angular/core'; + import { PrivacyComponent as BaseComponent } from '../../../../../app/info/privacy/privacy.component'; -import { InfoModule } from '../../../../../app/info/info.module'; +import { PrivacyContentComponent } from '../../../../../app/info/privacy/privacy-content/privacy-content.component'; @Component({ - selector: 'ds-privacy', - // styleUrls: ['./privacy.component.scss'], - styleUrls: ['../../../../../app/info/privacy/privacy.component.scss'], - // templateUrl: './privacy.component.html' - templateUrl: '../../../../../app/info/privacy/privacy.component.html', - standalone: true, - imports: [InfoModule] + selector: 'ds-themed-privacy', + // styleUrls: ['./privacy.component.scss'], + styleUrls: ['../../../../../app/info/privacy/privacy.component.scss'], + // templateUrl: './privacy.component.html' + templateUrl: '../../../../../app/info/privacy/privacy.component.html', + standalone: true, + imports: [PrivacyContentComponent], }) /** diff --git a/src/themes/era/app/item-page/alerts/item-alerts.component.ts b/src/themes/era/app/item-page/alerts/item-alerts.component.ts index a5a38349cbb..4cfa632f02f 100644 --- a/src/themes/era/app/item-page/alerts/item-alerts.component.ts +++ b/src/themes/era/app/item-page/alerts/item-alerts.component.ts @@ -1,18 +1,28 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; -import { ItemAlertsComponent as BaseComponent } from '../../../../../app/item-page/alerts/item-alerts.component'; -import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; -import { SharedModule } from '../../../../../app/shared/shared.module'; -import { NgIf } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ItemAlertsComponent as BaseComponent } from '../../../../../app/item-page/alerts/item-alerts.component'; +import { AlertComponent } from '../../../../../app/shared/alert/alert.component'; @Component({ - selector: 'ds-item-alerts', - // templateUrl: './item-alerts.component.html', - templateUrl: '../../../../../app/item-page/alerts/item-alerts.component.html', - // styleUrls: ['./item-alerts.component.scss'], - styleUrls: ['../../../../../app/item-page/alerts/item-alerts.component.scss'], - standalone: true, - imports: [NgIf, SharedModule, RouterLink, TranslateModule] + selector: 'ds-themed-item-alerts', + // templateUrl: './item-alerts.component.html', + templateUrl: '../../../../../app/item-page/alerts/item-alerts.component.html', + // styleUrls: ['./item-alerts.component.scss'], + styleUrls: ['../../../../../app/item-page/alerts/item-alerts.component.scss'], + standalone: true, + imports: [ + AlertComponent, + NgIf, + TranslateModule, + RouterLink, + AsyncPipe, + ], }) export class ItemAlertsComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/edit-item-page/item-status/item-status.component.ts b/src/themes/era/app/item-page/edit-item-page/item-status/item-status.component.ts index 10c0af43b8f..a39d2e4cda7 100644 --- a/src/themes/era/app/item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/themes/era/app/item-page/edit-item-page/item-status/item-status.component.ts @@ -1,22 +1,42 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { fadeIn, fadeInOut } from '../../../../../../app/shared/animations/fade'; -import { ItemStatusComponent as BaseComponent } from '../../../../../../app/item-page/edit-item-page/item-status/item-status.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { EditItemPageModule } from '../../../../../../app/item-page/edit-item-page/edit-item-page.module'; +import { + AsyncPipe, + NgClass, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; import { RouterLink } from '@angular/router'; -import { NgFor, NgIf, NgClass, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ItemOperationComponent } from '../../../../../../app/item-page/edit-item-page/item-operation/item-operation.component'; +import { ItemStatusComponent as BaseComponent } from '../../../../../../app/item-page/edit-item-page/item-status/item-status.component'; +import { + fadeIn, + fadeInOut, +} from '../../../../../../app/shared/animations/fade'; @Component({ - selector: 'ds-item-status', - // templateUrl: './item-status.component.html', - templateUrl: '../../../../../../app/item-page/edit-item-page/item-status/item-status.component.html', - changeDetection: ChangeDetectionStrategy.Default, - animations: [ - fadeIn, - fadeInOut - ], - standalone: true, - imports: [NgFor, NgIf, RouterLink, NgClass, EditItemPageModule, AsyncPipe, TranslateModule] + selector: 'ds-themed-item-status', + // templateUrl: './item-status.component.html', + templateUrl: '../../../../../../app/item-page/edit-item-page/item-status/item-status.component.html', + changeDetection: ChangeDetectionStrategy.Default, + animations: [ + fadeIn, + fadeInOut, + ], + standalone: true, + imports: [ + TranslateModule, + NgForOf, + AsyncPipe, + NgIf, + RouterLink, + ItemOperationComponent, + NgClass, + ], }) export class ItemStatusComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.html index e69de29bb2d..32a99594e15 100644 --- a/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -0,0 +1,44 @@ + +
+
+

{{"item.page.filesection.original.bundle" | translate}}

+ + + +
+
+ +
+
+
+
{{"item.page.filesection.name" | translate}}
+
{{ dsoNameService.getName(file) }}
+ +
{{"item.page.filesection.size" | translate}}
+
{{(file.sizeBytes) | dsFileSize }}
+ + +
{{"item.page.filesection.format" | translate}}
+
{{(file.format | async)?.payload?.description}}
+ + +
{{"item.page.filesection.description" | translate}}
+
{{file.firstMetadataValue("dc.description")}}
+
+
+
+
+ + {{"item.page.filesection.download" | translate}} + +
+
+
+
+
+
diff --git a/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.ts b/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.ts index 8bfe371a16e..e4d6ca919cd 100644 --- a/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/themes/era/app/item-page/full/field-components/file-section/full-file-section.component.ts @@ -1,19 +1,38 @@ -import { Component } from '@angular/core'; import { - FullFileSectionComponent as BaseComponent -} from '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component'; + AsyncPipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { NgIf, NgFor, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; + +import { FullFileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component'; +import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { PaginationComponent } from '../../../../../../../app/shared/pagination/pagination.component'; +import { FileSizePipe } from '../../../../../../../app/shared/utils/file-size-pipe'; +import { VarDirective } from '../../../../../../../app/shared/utils/var.directive'; +import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; @Component({ - selector: 'ds-item-page-full-file-section', - // styleUrls: ['./full-file-section.component.scss'], - styleUrls: ['../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component.scss'], - // templateUrl: './full-file-section.component.html', - templateUrl: '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component.html', - standalone: true, - imports: [SharedModule, NgIf, NgFor, AsyncPipe, TranslateModule] + selector: 'ds-themed-item-page-full-file-section', + // styleUrls: ['./full-file-section.component.scss'], + styleUrls: ['../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component.scss'], + templateUrl: './full-file-section.component.html', + // templateUrl: '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component.html', + standalone: true, + imports: [ + PaginationComponent, + NgIf, + TranslateModule, + AsyncPipe, + VarDirective, + ThemedThumbnailComponent, + NgForOf, + ThemedFileDownloadLinkComponent, + FileSizePipe, + MetadataFieldWrapperComponent, + ], }) export class FullFileSectionComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/full/full-item-page.component.ts b/src/themes/era/app/item-page/full/full-item-page.component.ts index 2a2899e578c..c9b17cd200d 100644 --- a/src/themes/era/app/item-page/full/full-item-page.component.ts +++ b/src/themes/era/app/item-page/full/full-item-page.component.ts @@ -1,14 +1,28 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { fadeInOut } from '../../../../../app/shared/animations/fade'; -import { FullItemPageComponent as BaseComponent } from '../../../../../app/item-page/full/full-item-page.component'; -import { TranslateModule } from '@ngx-translate/core'; +import { + AsyncPipe, + KeyValuePipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; import { RouterLink } from '@angular/router'; -import { DsoPageModule } from '../../../../../app/shared/dso-page/dso-page.module'; -import { StatisticsModule } from '../../../../../app/statistics/statistics.module'; -import { ItemVersionsModule } from '../../../../../app/item-page/versions/item-versions.module'; -import { ItemPageModule } from '../../../../../app/item-page/item-page.module'; -import { NgIf, NgFor, AsyncPipe, KeyValuePipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ThemedItemAlertsComponent } from '../../../../../app/item-page/alerts/themed-item-alerts.component'; +import { CollectionsComponent } from '../../../../../app/item-page/field-components/collections/collections.component'; +import { ThemedFullFileSectionComponent } from '../../../../../app/item-page/full/field-components/file-section/themed-full-file-section.component'; +import { FullItemPageComponent as BaseComponent } from '../../../../../app/item-page/full/full-item-page.component'; +import { ThemedItemPageTitleFieldComponent } from '../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { ItemVersionsComponent } from '../../../../../app/item-page/versions/item-versions.component'; +import { ItemVersionsNoticeComponent } from '../../../../../app/item-page/versions/notice/item-versions-notice.component'; +import { fadeInOut } from '../../../../../app/shared/animations/fade'; +import { DsoEditMenuComponent } from '../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { ErrorComponent } from '../../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; /** * This component renders a full item page. @@ -16,15 +30,32 @@ import { SharedModule } from '../../../../../app/shared/shared.module'; */ @Component({ - selector: 'ds-full-item-page', - // styleUrls: ['./full-item-page.component.scss'], - styleUrls: ['../../../../../app/item-page/full/full-item-page.component.scss'], - // templateUrl: './full-item-page.component.html', - templateUrl: '../../../../../app/item-page/full/full-item-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut], - standalone: true, - imports: [SharedModule, NgIf, ItemPageModule, ItemVersionsModule, StatisticsModule, DsoPageModule, RouterLink, NgFor, AsyncPipe, KeyValuePipe, TranslateModule] + selector: 'ds-themed-full-item-page', + // styleUrls: ['./full-item-page.component.scss'], + styleUrls: ['../../../../../app/item-page/full/full-item-page.component.scss'], + // templateUrl: './full-item-page.component.html', + templateUrl: '../../../../../app/item-page/full/full-item-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [fadeInOut], + standalone: true, + imports: [ + ErrorComponent, + ThemedLoadingComponent, + TranslateModule, + ThemedFullFileSectionComponent, + CollectionsComponent, + ItemVersionsComponent, + NgIf, + NgForOf, + AsyncPipe, + KeyValuePipe, + RouterLink, + ThemedItemPageTitleFieldComponent, + DsoEditMenuComponent, + ItemVersionsNoticeComponent, + ThemedItemAlertsComponent, + VarDirective, + ], }) export class FullItemPageComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts b/src/themes/era/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts index aff802dae24..da2f62eb9b3 100644 --- a/src/themes/era/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts +++ b/src/themes/era/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts @@ -1,18 +1,20 @@ -import { Component } from '@angular/core'; -import { - MediaViewerImageComponent as BaseComponent -} from '../../../../../../app/item-page/media-viewer/media-viewer-image/media-viewer-image.component'; import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; import { NgxGalleryModule } from '@kolkov/ngx-gallery'; +import { MediaViewerImageComponent as BaseComponent } from '../../../../../../app/item-page/media-viewer/media-viewer-image/media-viewer-image.component'; + @Component({ - selector: 'ds-media-viewer-image', - // templateUrl: './media-viewer-image.component.html', - templateUrl: '../../../../../../app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.html', - // styleUrls: ['./media-viewer-image.component.scss'], - styleUrls: ['../../../../../../app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss'], - standalone: true, - imports: [NgxGalleryModule, AsyncPipe] + selector: 'ds-themed-media-viewer-image', + // templateUrl: './media-viewer-image.component.html', + templateUrl: '../../../../../../app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.html', + // styleUrls: ['./media-viewer-image.component.scss'], + styleUrls: ['../../../../../../app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss'], + standalone: true, + imports: [ + NgxGalleryModule, + AsyncPipe, + ], }) export class MediaViewerImageComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts b/src/themes/era/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts index be3a69aad8e..0efa0130640 100644 --- a/src/themes/era/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts +++ b/src/themes/era/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts @@ -1,19 +1,28 @@ -import { Component } from '@angular/core'; import { - MediaViewerVideoComponent as BaseComponent -} from '../../../../../../app/item-page/media-viewer/media-viewer-video/media-viewer-video.component'; -import { TranslateModule } from '@ngx-translate/core'; + NgForOf, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { NgIf, NgFor } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { MediaViewerVideoComponent as BaseComponent } from '../../../../../../app/item-page/media-viewer/media-viewer-video/media-viewer-video.component'; +import { BtnDisabledDirective } from '../../../../../../app/shared/btn-disabled.directive'; @Component({ - selector: 'ds-media-viewer-video', - // templateUrl: './media-viewer-video.component.html', - templateUrl: '../../../../../../app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html', - // styleUrls: ['./media-viewer-video.component.scss'], - styleUrls: ['../../../../../../app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss'], - standalone: true, - imports: [NgIf, NgFor, NgbDropdownModule, TranslateModule] + selector: 'ds-themed-media-viewer-video', + // templateUrl: './media-viewer-video.component.html', + templateUrl: '../../../../../../app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html', + // styleUrls: ['./media-viewer-video.component.scss'], + styleUrls: ['../../../../../../app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss'], + standalone: true, + imports: [ + NgForOf, + NgbDropdownModule, + TranslateModule, + NgIf, + BtnDisabledDirective, + ], }) export class MediaViewerVideoComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/media-viewer/media-viewer.component.ts b/src/themes/era/app/item-page/media-viewer/media-viewer.component.ts index 044faeb7e3a..35336e6086b 100644 --- a/src/themes/era/app/item-page/media-viewer/media-viewer.component.ts +++ b/src/themes/era/app/item-page/media-viewer/media-viewer.component.ts @@ -1,21 +1,34 @@ -import { Component } from '@angular/core'; import { - MediaViewerComponent as BaseComponent -} from '../../../../../app/item-page/media-viewer/media-viewer.component'; + AsyncPipe, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { ThumbnailComponent } from '../../thumbnail/thumbnail.component'; -import { ItemPageModule } from '../../../../../app/item-page/item-page.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; + +import { MediaViewerComponent as BaseComponent } from '../../../../../app/item-page/media-viewer/media-viewer.component'; +import { ThemedMediaViewerImageComponent } from '../../../../../app/item-page/media-viewer/media-viewer-image/themed-media-viewer-image.component'; +import { ThemedMediaViewerVideoComponent } from '../../../../../app/item-page/media-viewer/media-viewer-video/themed-media-viewer-video.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; +import { ThemedThumbnailComponent } from '../../../../../app/thumbnail/themed-thumbnail.component'; @Component({ - selector: 'ds-media-viewer', - // templateUrl: './media-viewer.component.html', - templateUrl: '../../../../../app/item-page/media-viewer/media-viewer.component.html', - // styleUrls: ['./media-viewer.component.scss'], - styleUrls: ['../../../../../app/item-page/media-viewer/media-viewer.component.scss'], - standalone: true, - imports: [SharedModule, NgIf, ItemPageModule, ThumbnailComponent, AsyncPipe, TranslateModule] + selector: 'ds-themed-media-viewer', + // templateUrl: './media-viewer.component.html', + templateUrl: '../../../../../app/item-page/media-viewer/media-viewer.component.html', + // styleUrls: ['./media-viewer.component.scss'], + styleUrls: ['../../../../../app/item-page/media-viewer/media-viewer.component.scss'], + standalone: true, + imports: [ + ThemedMediaViewerImageComponent, + ThemedThumbnailComponent, + AsyncPipe, + NgIf, + ThemedMediaViewerVideoComponent, + TranslateModule, + ThemedLoadingComponent, + VarDirective, + ], }) export class MediaViewerComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/simple/field-components/file-section/file-section.component.ts b/src/themes/era/app/item-page/simple/field-components/file-section/file-section.component.ts index 60807e88d19..e39f7536eb0 100644 --- a/src/themes/era/app/item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/themes/era/app/item-page/simple/field-components/file-section/file-section.component.ts @@ -1,17 +1,30 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide'; -import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component'; import { TranslateModule } from '@ngx-translate/core'; -import { NgIf, NgFor, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; + +import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component'; +import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide'; +import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; +import { ThemedLoadingComponent } from '../../../../../../../app/shared/loading/themed-loading.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { FileSizePipe } from '../../../../../../../app/shared/utils/file-size-pipe'; +import { VarDirective } from '../../../../../../../app/shared/utils/var.directive'; @Component({ - selector: 'ds-item-page-file-section', - // templateUrl: './file-section.component.html', - templateUrl: '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component.html', - animations: [slideSidebarPadding], - standalone: true, - imports: [SharedModule, NgIf, NgFor, AsyncPipe, TranslateModule] + selector: 'ds-themed-item-page-file-section', + // templateUrl: './file-section.component.html', + templateUrl: '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component.html', + animations: [slideSidebarPadding], + standalone: true, + imports: [ + CommonModule, + ThemedFileDownloadLinkComponent, + MetadataFieldWrapperComponent, + ThemedLoadingComponent, + TranslateModule, + FileSizePipe, + VarDirective, + ], }) export class FileSectionComponent extends BaseComponent { diff --git a/src/themes/era/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts b/src/themes/era/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts index 9154bb3423d..4b0bfa4c91b 100644 --- a/src/themes/era/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts +++ b/src/themes/era/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts @@ -1,16 +1,15 @@ +import { NgIf } from '@angular/common'; import { Component } from '@angular/core'; -import { - ItemPageTitleFieldComponent as BaseComponent -} from '../../../../../../../../app/item-page/simple/field-components/specific-field/title/item-page-title-field.component'; import { TranslateModule } from '@ngx-translate/core'; -import { NgIf } from '@angular/common'; + +import { ItemPageTitleFieldComponent as BaseComponent } from '../../../../../../../../app/item-page/simple/field-components/specific-field/title/item-page-title-field.component'; @Component({ - selector: 'ds-item-page-title-field', - // templateUrl: './item-page-title-field.component.html', - templateUrl: '../../../../../../../../app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.html', - standalone: true, - imports: [NgIf, TranslateModule] + selector: 'ds-themed-item-page-title-field', + // templateUrl: './item-page-title-field.component.html', + templateUrl: '../../../../../../../../app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.html', + standalone: true, + imports: [NgIf, TranslateModule], }) export class ItemPageTitleFieldComponent extends BaseComponent { } diff --git a/src/themes/era/app/item-page/simple/item-page.component.ts b/src/themes/era/app/item-page/simple/item-page.component.ts index 409dc2a925b..7ffd4b68d14 100644 --- a/src/themes/era/app/item-page/simple/item-page.component.ts +++ b/src/themes/era/app/item-page/simple/item-page.component.ts @@ -1,12 +1,24 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ThemedItemAlertsComponent } from '../../../../../app/item-page/alerts/themed-item-alerts.component'; import { ItemPageComponent as BaseComponent } from '../../../../../app/item-page/simple/item-page.component'; +import { NotifyRequestsStatusComponent } from '../../../../../app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component'; +import { QaEventNotificationComponent } from '../../../../../app/item-page/simple/qa-event-notification/qa-event-notification.component'; +import { ItemVersionsComponent } from '../../../../../app/item-page/versions/item-versions.component'; +import { ItemVersionsNoticeComponent } from '../../../../../app/item-page/versions/notice/item-versions-notice.component'; import { fadeInOut } from '../../../../../app/shared/animations/fade'; -import { TranslateModule } from '@ngx-translate/core'; -import { StatisticsModule } from '../../../../../app/statistics/statistics.module'; -import { ItemVersionsModule } from '../../../../../app/item-page/versions/item-versions.module'; -import { ItemPageModule } from '../../../../../app/item-page/item-page.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../app/shared/shared.module'; +import { ErrorComponent } from '../../../../../app/shared/error/error.component'; +import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; +import { ListableObjectComponentLoaderComponent } from '../../../../../app/shared/object-collection/shared/listable-object/listable-object-component-loader.component'; +import { VarDirective } from '../../../../../app/shared/utils/var.directive'; /** * This component renders a simple item page. @@ -14,15 +26,28 @@ import { SharedModule } from '../../../../../app/shared/shared.module'; * All fields of the item that should be displayed, are defined in its template. */ @Component({ - selector: 'ds-item-page', - // styleUrls: ['./item-page.component.scss'], - styleUrls: ['../../../../../app/item-page/simple/item-page.component.scss'], - templateUrl: './item-page.component.html', - // templateUrl: '../../../../../app/item-page/simple/item-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut], - standalone: true, - imports: [SharedModule, NgIf, ItemPageModule, ItemVersionsModule, StatisticsModule, AsyncPipe, TranslateModule] + selector: 'ds-themed-item-page', + // styleUrls: ['./item-page.component.scss'], + styleUrls: ['../../../../../app/item-page/simple/item-page.component.scss'], + // templateUrl: './item-page.component.html', + templateUrl: '../../../../../app/item-page/simple/item-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [fadeInOut], + standalone: true, + imports: [ + VarDirective, + ThemedItemAlertsComponent, + ItemVersionsNoticeComponent, + ListableObjectComponentLoaderComponent, + ItemVersionsComponent, + ErrorComponent, + ThemedLoadingComponent, + TranslateModule, + AsyncPipe, + NgIf, + NotifyRequestsStatusComponent, + QaEventNotificationComponent, + ], }) export class ItemPageComponent extends BaseComponent { diff --git a/src/themes/era/app/item-page/simple/item-types/publication/publication.component.ts b/src/themes/era/app/item-page/simple/item-types/publication/publication.component.ts index 8899e859991..ce56724e4ab 100644 --- a/src/themes/era/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/themes/era/app/item-page/simple/item-types/publication/publication.component.ts @@ -1,16 +1,33 @@ -import { ChangeDetectionStrategy, 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 { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + import { Context } from '../../../../../../../app/core/shared/context.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { CollectionsComponent } from '../../../../../../../app/item-page/field-components/collections/collections.component'; +import { ThemedMediaViewerComponent } from '../../../../../../../app/item-page/media-viewer/themed-media-viewer.component'; +import { MiradorViewerComponent } from '../../../../../../../app/item-page/mirador-viewer/mirador-viewer.component'; +import { ThemedFileSectionComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/themed-file-section.component'; +import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; +import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; import { PublicationComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/publication/publication.component'; -import { TranslateModule } from '@ngx-translate/core'; -import { RouterLink } from '@angular/router'; -import { ItemSharedModule } from '../../../../../../../app/item-page/item-shared.module'; -import { DsoPageModule } from '../../../../../../../app/shared/dso-page/dso-page.module'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; -import { ItemPageModule } from '../../../../../../../app/item-page/item-page.module'; -import { ResultsBackButtonModule } from '../../../../../../../app/shared/results-back-button/results-back-button.module'; -import { NgIf, AsyncPipe } from '@angular/common'; +import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; +import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; +import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; /** * Component that represents a publication Item page @@ -18,14 +35,14 @@ import { NgIf, AsyncPipe } from '@angular/common'; @listableObjectComponent('Publication', ViewMode.StandalonePage, Context.Any, 'custom') @Component({ - selector: 'ds-publication', - // styleUrls: ['./publication.component.scss'], - styleUrls: ['../../../../../../../app/item-page/simple/item-types/publication/publication.component.scss'], - // templateUrl: './publication.component.html', - templateUrl: '../../../../../../../app/item-page/simple/item-types/publication/publication.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgIf, ResultsBackButtonModule, ItemPageModule, SharedModule, DsoPageModule, ItemSharedModule, RouterLink, AsyncPipe, TranslateModule] + selector: 'ds-publication', + // styleUrls: ['./publication.component.scss'], + styleUrls: ['../../../../../../../app/item-page/simple/item-types/publication/publication.component.scss'], + // templateUrl: './publication.component.html', + templateUrl: '../../../../../../../app/item-page/simple/item-types/publication/publication.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgIf, ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, RouterLink, AsyncPipe, TranslateModule], }) export class PublicationComponent extends BaseComponent { diff --git a/src/themes/era/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/era/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index d3f0a10fb4a..6fbb035d2f2 100644 --- a/src/themes/era/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/themes/era/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -1,35 +1,70 @@ -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'; + AsyncPipe, + NgIf, +} from '@angular/common'; import { - UntypedItemComponent as BaseComponent -} from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; -import { TranslateModule } from '@ngx-translate/core'; + ChangeDetectionStrategy, + Component, +} from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ItemSharedModule } from '../../../../../../../app/item-page/item-shared.module'; -import { DsoPageModule } from '../../../../../../../app/shared/dso-page/dso-page.module'; -import { SharedModule } from '../../../../../../../app/shared/shared.module'; -import { ItemPageModule } from '../../../../../../../app/item-page/item-page.module'; -import { ResultsBackButtonModule } from '../../../../../../../app/shared/results-back-button/results-back-button.module'; -import { NgIf, AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { Item } from '../../../../../../../app/core/shared/item.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { CollectionsComponent } from '../../../../../../../app/item-page/field-components/collections/collections.component'; +import { ThemedMediaViewerComponent } from '../../../../../../../app/item-page/media-viewer/themed-media-viewer.component'; +import { MiradorViewerComponent } from '../../../../../../../app/item-page/mirador-viewer/mirador-viewer.component'; +import { ThemedFileSectionComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/themed-file-section.component'; +import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { ItemPageCcLicenseFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; +import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; +import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; +import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; +import { UntypedItemComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; +import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; +import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.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, - standalone: true, - imports: [NgIf, ResultsBackButtonModule, ItemPageModule, SharedModule, DsoPageModule, ItemSharedModule, RouterLink, AsyncPipe, TranslateModule] + 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, + standalone: true, + imports: [ + NgIf, + ThemedResultsBackButtonComponent, + MiradorViewerComponent, + ThemedItemPageTitleFieldComponent, + DsoEditMenuComponent, + MetadataFieldWrapperComponent, + ThemedThumbnailComponent, + ThemedMediaViewerComponent, + ThemedFileSectionComponent, + ItemPageDateFieldComponent, + ThemedMetadataRepresentationListComponent, + GenericItemPageFieldComponent, + ItemPageAbstractFieldComponent, + ItemPageUriFieldComponent, + CollectionsComponent, + RouterLink, + AsyncPipe, + TranslateModule, + ItemPageCcLicenseFieldComponent, + ], }) -export class UntypedItemComponent extends BaseComponent { -} +export class UntypedItemComponent extends BaseComponent {} diff --git a/src/themes/era/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/themes/era/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index f8c67a5e7f9..6d2ddb8f1f3 100644 --- a/src/themes/era/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/themes/era/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -1,15 +1,23 @@ -import { MetadataRepresentationListComponent as BaseComponent } from '../../../../../../app/item-page/simple/metadata-representation-list/metadata-representation-list.component'; +import { + AsyncPipe, + NgFor, + NgIf, +} from '@angular/common'; import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { NgFor, NgIf, AsyncPipe } from '@angular/common'; -import { SharedModule } from '../../../../../../app/shared/shared.module'; + +import { MetadataRepresentationListComponent as BaseComponent } from '../../../../../../app/item-page/simple/metadata-representation-list/metadata-representation-list.component'; +import { ThemedLoadingComponent } from '../../../../../../app/shared/loading/themed-loading.component'; +import { MetadataFieldWrapperComponent } from '../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { MetadataRepresentationLoaderComponent } from '../../../../../../app/shared/metadata-representation/metadata-representation-loader.component'; +import { VarDirective } from '../../../../../../app/shared/utils/var.directive'; @Component({ - selector: 'ds-metadata-representation-list', - // templateUrl: './metadata-representation-list.component.html' - templateUrl: '../../../../../../app/item-page/simple/metadata-representation-list/metadata-representation-list.component.html', - standalone: true, - imports: [SharedModule, NgFor, NgIf, AsyncPipe, TranslateModule] + selector: 'ds-themed-metadata-representation-list', + // templateUrl: './metadata-representation-list.component.html' + templateUrl: '../../../../../../app/item-page/simple/metadata-representation-list/metadata-representation-list.component.html', + standalone: true, + imports: [MetadataFieldWrapperComponent, NgFor, VarDirective, MetadataRepresentationLoaderComponent, NgIf, ThemedLoadingComponent, AsyncPipe, TranslateModule], }) export class MetadataRepresentationListComponent extends BaseComponent { diff --git a/src/themes/era/app/login-page/login-page.component.ts b/src/themes/era/app/login-page/login-page.component.ts index 60d9d708680..1e7daae653a 100644 --- a/src/themes/era/app/login-page/login-page.component.ts +++ b/src/themes/era/app/login-page/login-page.component.ts @@ -1,19 +1,20 @@ import { Component } from '@angular/core'; -import { LoginPageComponent as BaseComponent } from '../../../../app/login-page/login-page.component'; import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../../app/shared/shared.module'; +import { ThemedLogInComponent } from 'src/app/shared/log-in/themed-log-in.component'; + +import { LoginPageComponent as BaseComponent } from '../../../../app/login-page/login-page.component'; /** * This component represents the login page */ @Component({ - selector: 'ds-login-page', - // styleUrls: ['./login-page.component.scss'], - styleUrls: ['../../../../app/login-page/login-page.component.scss'], - // templateUrl: './login-page.component.html' - templateUrl: '../../../../app/login-page/login-page.component.html', - standalone: true, - imports: [SharedModule, TranslateModule] + selector: 'ds-themed-login-page', + // styleUrls: ['./login-page.component.scss'], + styleUrls: ['../../../../app/login-page/login-page.component.scss'], + // templateUrl: './login-page.component.html' + templateUrl: '../../../../app/login-page/login-page.component.html', + standalone: true, + imports: [ThemedLogInComponent, TranslateModule], }) export class LoginPageComponent extends BaseComponent { } diff --git a/src/themes/era/app/logout-page/logout-page.component.ts b/src/themes/era/app/logout-page/logout-page.component.ts index 5a839d96d85..97f82c17287 100644 --- a/src/themes/era/app/logout-page/logout-page.component.ts +++ b/src/themes/era/app/logout-page/logout-page.component.ts @@ -1,16 +1,17 @@ import { Component } from '@angular/core'; -import { LogoutPageComponent as BaseComponent} from '../../../../app/logout-page/logout-page.component'; import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../../app/shared/shared.module'; + +import { LogoutPageComponent as BaseComponent } from '../../../../app/logout-page/logout-page.component'; +import { LogOutComponent } from '../../../../app/shared/log-out/log-out.component'; @Component({ - selector: 'ds-logout-page', - // styleUrls: ['./logout-page.component.scss'], - styleUrls: ['../../../../app/logout-page/logout-page.component.scss'], - // templateUrl: './logout-page.component.html' - templateUrl: '../../../../app/logout-page/logout-page.component.html', - standalone: true, - imports: [SharedModule, TranslateModule] + selector: 'ds-themed-logout-page', + // styleUrls: ['./logout-page.component.scss'], + styleUrls: ['../../../../app/logout-page/logout-page.component.scss'], + // templateUrl: './logout-page.component.html' + templateUrl: '../../../../app/logout-page/logout-page.component.html', + standalone: true, + imports: [LogOutComponent, TranslateModule], }) export class LogoutPageComponent extends BaseComponent { } diff --git a/src/themes/era/app/lookup-by-id/objectnotfound/objectnotfound.component.ts b/src/themes/era/app/lookup-by-id/objectnotfound/objectnotfound.component.ts index f4185971bbe..ea599d8a61f 100644 --- a/src/themes/era/app/lookup-by-id/objectnotfound/objectnotfound.component.ts +++ b/src/themes/era/app/lookup-by-id/objectnotfound/objectnotfound.component.ts @@ -1,17 +1,21 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ObjectNotFoundComponent as BaseComponent } from '../../../../../app/lookup-by-id/objectnotfound/objectnotfound.component'; -import { TranslateModule } from '@ngx-translate/core'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ObjectNotFoundComponent as BaseComponent } from '../../../../../app/lookup-by-id/objectnotfound/objectnotfound.component'; @Component({ - selector: 'ds-objnotfound', - // styleUrls: ['./objectnotfound.component.scss'], - styleUrls: ['../../../../../app/lookup-by-id/objectnotfound/objectnotfound.component.scss'], - // templateUrl: './objectnotfound.component.html', - templateUrl: '../../../../../app/lookup-by-id/objectnotfound/objectnotfound.component.html', - changeDetection: ChangeDetectionStrategy.Default, - standalone: true, - imports: [RouterLink, TranslateModule] + selector: 'ds-themed-objnotfound', + // styleUrls: ['./objectnotfound.component.scss'], + styleUrls: ['../../../../../app/lookup-by-id/objectnotfound/objectnotfound.component.scss'], + // templateUrl: './objectnotfound.component.html', + templateUrl: '../../../../../app/lookup-by-id/objectnotfound/objectnotfound.component.html', + changeDetection: ChangeDetectionStrategy.Default, + standalone: true, + imports: [RouterLink, TranslateModule], }) /** diff --git a/src/themes/era/app/my-dspace-page/my-dspace-page.component.ts b/src/themes/era/app/my-dspace-page/my-dspace-page.component.ts index e8c1f4f836f..39156a0ac75 100644 --- a/src/themes/era/app/my-dspace-page/my-dspace-page.component.ts +++ b/src/themes/era/app/my-dspace-page/my-dspace-page.component.ts @@ -1,31 +1,51 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; + +import { + MyDSpaceConfigurationService, + SEARCH_CONFIG_SERVICE, +} from '../../../../app/my-dspace-page/my-dspace-configuration.service'; +import { MyDSpaceNewSubmissionComponent } from '../../../../app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component'; +import { MyDSpacePageComponent as BaseComponent } from '../../../../app/my-dspace-page/my-dspace-page.component'; +import { MyDspaceQaEventsNotificationsComponent } from '../../../../app/my-dspace-page/my-dspace-qa-events-notifications/my-dspace-qa-events-notifications.component'; +import { SuggestionsNotificationComponent } from '../../../../app/notifications/suggestions-notification/suggestions-notification.component'; import { pushInOut } from '../../../../app/shared/animations/push'; -import { MyDSpacePageComponent as BaseComponent, SEARCH_CONFIG_SERVICE } from '../../../../app/my-dspace-page/my-dspace-page.component'; -import { MyDSpaceConfigurationService } from '../../../../app/my-dspace-page/my-dspace-configuration.service'; -import { SearchModule } from '../../../../app/shared/search/search.module'; -import { NgIf, AsyncPipe } from '@angular/common'; -import { MyDSpacePageModule } from '../../../../app/my-dspace-page/my-dspace-page.module'; -import { SharedModule } from '../../../../app/shared/shared.module'; +import { RoleDirective } from '../../../../app/shared/roles/role.directive'; +import { ThemedSearchComponent } from '../../../../app/shared/search/themed-search.component'; /** * This component represents the whole mydspace page */ @Component({ - selector: 'ds-my-dspace-page', - // styleUrls: ['./my-dspace-page.component.scss'], - styleUrls: ['../../../../app/my-dspace-page/my-dspace-page.component.scss'], - // templateUrl: './my-dspace-page.component.html', - templateUrl: '../../../../app/my-dspace-page/my-dspace-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [pushInOut], - providers: [ - { - provide: SEARCH_CONFIG_SERVICE, - useClass: MyDSpaceConfigurationService - } - ], - standalone: true, - imports: [SharedModule, MyDSpacePageModule, NgIf, SearchModule, AsyncPipe] + selector: 'ds-themed-my-dspace-page', + // styleUrls: ['./my-dspace-page.component.scss'], + styleUrls: ['../../../../app/my-dspace-page/my-dspace-page.component.scss'], + // templateUrl: './my-dspace-page.component.html', + templateUrl: '../../../../app/my-dspace-page/my-dspace-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: MyDSpaceConfigurationService, + }, + ], + standalone: true, + imports: [ + ThemedSearchComponent, + MyDSpaceNewSubmissionComponent, + AsyncPipe, + RoleDirective, + NgIf, + SuggestionsNotificationComponent, + MyDspaceQaEventsNotificationsComponent, + ], }) export class MyDSpacePageComponent extends BaseComponent { } diff --git a/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index e69de29bb2d..71ccad083b4 100644 --- a/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -0,0 +1,37 @@ +
+ + + + + + + +
diff --git a/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss b/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss index 1a8fc822006..fa78502ea95 100644 --- a/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss +++ b/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss @@ -1,6 +1,28 @@ :host { - ::ng-deep a.nav-link.dropdown-toggle, - ::ng-deep a.nav-link.dropdown-toggle:hover { - color: white; + .ds-menu-item-wrapper { + position: relative; // align dropdown menu with respect to this element + } + + .dropdown-menu { + overflow: hidden; + @include media-breakpoint-down(sm) { + border: 0; + background-color: var(--ds-expandable-navbar-bg); } + @include media-breakpoint-up(md) { + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: var(--ds-navbar-dropdown-bg); + } + } + + .toggle-menu-icon { + &, &:hover { + text-decoration: none; + } + } +} + +.dropdown-overide a { + color: #333!important; } \ No newline at end of file diff --git a/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 9a2e93c69d0..3d3aa869069 100644 --- a/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/themes/era/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -1,27 +1,32 @@ -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'; -import { NgComponentOutlet, NgIf, NgFor, AsyncPipe } from '@angular/common'; + AsyncPipe, + NgComponentOutlet, + NgFor, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; import { RouterLinkActive } from '@angular/router'; -import { SharedModule } from '../../../../../app/shared/shared.module'; -/** - * Represents an expandable section in the navbar - */ +import { ExpandableNavbarSectionComponent as BaseComponent } from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component'; +import { slide } from '../../../../../app/shared/animations/slide'; +import { HoverOutsideDirective } from '../../../../../app/shared/utils/hover-outside.directive'; + @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], - standalone: true, - imports: [SharedModule, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe] + selector: 'ds-themed-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], + standalone: true, + imports: [ + AsyncPipe, + HoverOutsideDirective, + NgComponentOutlet, + NgFor, + NgIf, + RouterLinkActive, + ], }) -@rendersSectionForMenu(MenuID.PUBLIC, true) export class ExpandableNavbarSectionComponent extends BaseComponent { } diff --git a/src/themes/era/app/navbar/navbar.component.html b/src/themes/era/app/navbar/navbar.component.html index 8eea359172f..395b29dfa14 100644 --- a/src/themes/era/app/navbar/navbar.component.html +++ b/src/themes/era/app/navbar/navbar.component.html @@ -1,20 +1,24 @@ -