mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge remote-tracking branch 'upstream/main' into CST-7216
This commit is contained in:
@@ -15,3 +15,6 @@ trim_trailing_whitespace = false
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.json5]
|
||||
ij_json_keep_blank_lines_in_code = 3
|
||||
|
@@ -7,7 +7,8 @@
|
||||
"eslint-plugin-jsdoc",
|
||||
"eslint-plugin-deprecation",
|
||||
"unused-imports",
|
||||
"eslint-plugin-lodash"
|
||||
"eslint-plugin-lodash",
|
||||
"eslint-plugin-jsonc"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
@@ -224,6 +225,42 @@
|
||||
"@angular-eslint/template/no-negated-async": "off",
|
||||
"@angular-eslint/template/eqeqeq": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.json5"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:jsonc/recommended-with-jsonc"
|
||||
],
|
||||
"rules": {
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"jsonc/comma-dangle": [
|
||||
"error",
|
||||
"always-multiline"
|
||||
],
|
||||
"jsonc/indent": [
|
||||
"error",
|
||||
2
|
||||
],
|
||||
"jsonc/key-spacing": [
|
||||
"error",
|
||||
{
|
||||
"beforeColon": false,
|
||||
"afterColon": true,
|
||||
"mode": "strict"
|
||||
}
|
||||
],
|
||||
"jsonc/no-dupe-keys": "off",
|
||||
"jsonc/quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"avoidEscape": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
57
.github/workflows/build.yml
vendored
57
.github/workflows/build.yml
vendored
@@ -15,12 +15,19 @@ jobs:
|
||||
env:
|
||||
# The ci step will test the dspace-angular code against DSpace REST.
|
||||
# Direct that step to utilize a DSpace REST service that has been started in docker.
|
||||
# NOTE: These settings should be kept in sync with those in [src]/docker/docker-compose-ci.yml
|
||||
DSPACE_REST_HOST: 127.0.0.1
|
||||
DSPACE_REST_PORT: 8080
|
||||
DSPACE_REST_NAMESPACE: '/server'
|
||||
DSPACE_REST_SSL: false
|
||||
# Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+
|
||||
DSPACE_UI_HOST: 127.0.0.1
|
||||
DSPACE_UI_PORT: 4000
|
||||
# Ensure all SSR caching is disabled in test environment
|
||||
DSPACE_CACHE_SERVERSIDE_BOTCACHE_MAX: 0
|
||||
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
|
||||
# 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"
|
||||
@@ -61,7 +68,7 @@ jobs:
|
||||
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
|
||||
- name: Get Yarn cache directory
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
- name: Cache Yarn dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -86,12 +93,16 @@ jobs:
|
||||
- name: Run specs (unit tests)
|
||||
run: yarn run test:headless
|
||||
|
||||
# Upload code coverage report to artifact (for one version of Node only),
|
||||
# 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
|
||||
# Upload coverage reports to Codecov (for one version of Node only)
|
||||
# https://github.com/codecov/codecov-action
|
||||
- name: Upload coverage to Codecov.io
|
||||
uses: codecov/codecov-action@v3
|
||||
if: matrix.node-version == '16.x'
|
||||
- name: Upload code coverage report to Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
if: matrix.node-version == '18.x'
|
||||
with:
|
||||
name: dspace-angular coverage report
|
||||
path: 'coverage/dspace-angular/lcov.info'
|
||||
retention-days: 14
|
||||
|
||||
# Using docker-compose start backend using CI configuration
|
||||
# and load assetstore from a cached copy
|
||||
@@ -105,11 +116,10 @@ jobs:
|
||||
# https://github.com/cypress-io/github-action
|
||||
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
|
||||
- name: Run e2e tests (integration tests)
|
||||
uses: cypress-io/github-action@v4
|
||||
uses: cypress-io/github-action@v5
|
||||
with:
|
||||
# Run tests in Chrome, headless mode
|
||||
# Run tests in Chrome, headless mode (default)
|
||||
browser: chrome
|
||||
headless: true
|
||||
# Start app before running tests (will be stopped automatically after tests finish)
|
||||
start: yarn run serve:ssr
|
||||
# Wait for backend & frontend to be available
|
||||
@@ -169,3 +179,32 @@ jobs:
|
||||
|
||||
- name: Shutdown Docker containers
|
||||
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.
|
||||
# See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954
|
||||
codecov:
|
||||
# Must run after 'tests' job above
|
||||
needs: tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Download artifacts from previous 'tests' job
|
||||
- name: Download coverage artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
with:
|
||||
action: codecov/codecov-action@v3
|
||||
# Try upload 5 times max
|
||||
attempt_limit: 5
|
||||
# Run again in 30 seconds
|
||||
attempt_delay: 30000
|
||||
|
30
.github/workflows/docker.yml
vendored
30
.github/workflows/docker.yml
vendored
@@ -88,3 +88,33 @@ jobs:
|
||||
# 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 }}
|
||||
|
2
.github/workflows/issue_opened.yml
vendored
2
.github/workflows/issue_opened.yml
vendored
@@ -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.3.0
|
||||
uses: actions/add-to-project@v0.5.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
|
||||
|
2
.github/workflows/label_merge_conflicts.yml
vendored
2
.github/workflows/label_merge_conflicts.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
# See: https://github.com/prince-chrismc/label-merge-conflicts-action
|
||||
- name: Auto-label PRs with merge conflicts
|
||||
uses: prince-chrismc/label-merge-conflicts-action@v2
|
||||
uses: prince-chrismc/label-merge-conflicts-action@v3
|
||||
# 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
|
||||
|
15
Dockerfile
15
Dockerfile
@@ -2,20 +2,27 @@
|
||||
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
|
||||
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
ADD . /app/
|
||||
EXPOSE 4000
|
||||
|
||||
# Ensure Python and other build tools are available
|
||||
# These are needed to install some node modules, especially on linux/arm64
|
||||
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /app
|
||||
ADD . /app/
|
||||
EXPOSE 4000
|
||||
|
||||
# We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com
|
||||
# See, for example https://github.com/yarnpkg/yarn/issues/5540
|
||||
RUN yarn install --network-timeout 300000
|
||||
|
||||
# When running in dev mode, 4GB of memory is required to build & launch the app.
|
||||
# This default setting can be overridden as needed in your shell, via an env file or in docker-compose.
|
||||
# See Docker environment var precedence: https://docs.docker.com/compose/environment-variables/envvars-precedence/
|
||||
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
|
||||
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
|
||||
# Listen / accept connections from all IP addresses.
|
||||
# NOTE: At this time it is only possible to run Docker container in Production mode
|
||||
# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485
|
||||
# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485
|
||||
ENV NODE_ENV development
|
||||
CMD yarn serve --host 0.0.0.0
|
||||
|
31
Dockerfile.dist
Normal file
31
Dockerfile.dist
Normal file
@@ -0,0 +1,31 @@
|
||||
# This image will be published as dspace/dspace-angular:$DSPACE_VERSION-dist
|
||||
# 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 .
|
||||
|
||||
FROM 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
|
||||
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --network-timeout 300000
|
||||
|
||||
ADD . /app/
|
||||
RUN yarn build:prod
|
||||
|
||||
FROM node:18-alpine
|
||||
RUN npm install --global pm2
|
||||
|
||||
COPY --chown=node:node --from=build /app/dist /app/dist
|
||||
COPY --chown=node:node config /app/config
|
||||
COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json
|
||||
|
||||
WORKDIR /app
|
||||
USER node
|
||||
ENV NODE_ENV production
|
||||
EXPOSE 4000
|
||||
CMD pm2-runtime start dspace-ui.json --json
|
@@ -266,7 +266,8 @@
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
"src/**/*.html",
|
||||
"src/**/*.json5"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
44
cypress.config.ts
Normal file
44
cypress.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
videosFolder: 'cypress/videos',
|
||||
screenshotsFolder: 'cypress/screenshots',
|
||||
fixturesFolder: 'cypress/fixtures',
|
||||
retries: {
|
||||
runMode: 2,
|
||||
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.
|
||||
// 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_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',
|
||||
// Search term (should return results) used in search tests
|
||||
DSPACE_TEST_SEARCH_TERM: 'test',
|
||||
// Collection used for submission tests
|
||||
DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection',
|
||||
DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144',
|
||||
// Account used to test basic submission process
|
||||
DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com',
|
||||
DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace',
|
||||
},
|
||||
e2e: {
|
||||
// Setup our plugins for e2e tests
|
||||
setupNodeEvents(on, config) {
|
||||
return require('./cypress/plugins/index.ts')(on, config);
|
||||
},
|
||||
// This is the base URL that Cypress will run all tests against
|
||||
// It can be overridden via the CYPRESS_BASE_URL environment variable
|
||||
// (By default we set this to a value which should work in most development environments)
|
||||
baseUrl: 'http://localhost:4000',
|
||||
},
|
||||
});
|
25
cypress.json
25
cypress.json
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"integrationFolder": "cypress/integration",
|
||||
"supportFile": "cypress/support/index.ts",
|
||||
"videosFolder": "cypress/videos",
|
||||
"screenshotsFolder": "cypress/screenshots",
|
||||
"pluginsFile": "cypress/plugins/index.ts",
|
||||
"fixturesFolder": "cypress/fixtures",
|
||||
"baseUrl": "http://127.0.0.1:4000",
|
||||
"retries": {
|
||||
"runMode": 2,
|
||||
"openMode": 0
|
||||
},
|
||||
"env": {
|
||||
"DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com",
|
||||
"DSPACE_TEST_ADMIN_PASSWORD": "dspace",
|
||||
"DSPACE_TEST_COMMUNITY": "0958c910-2037-42a9-81c7-dca80e3892b4",
|
||||
"DSPACE_TEST_COLLECTION": "282164f5-d325-4740-8dd1-fa4d6d3e7200",
|
||||
"DSPACE_TEST_ENTITY_PUBLICATION": "e98b0f27-5c19-49a0-960d-eb6ad5287067",
|
||||
"DSPACE_TEST_SEARCH_TERM": "test",
|
||||
"DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection",
|
||||
"DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144",
|
||||
"DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com",
|
||||
"DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace"
|
||||
}
|
||||
}
|
@@ -1,10 +1,10 @@
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
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/' + TEST_ENTITY_PUBLICATION);
|
||||
cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
|
||||
|
||||
// Wait for breadcrumbs to be visible
|
||||
cy.get('ds-breadcrumbs').should('be.visible');
|
@@ -1,13 +1,13 @@
|
||||
import { TEST_COLLECTION } from 'cypress/support';
|
||||
import { TEST_COLLECTION } from 'cypress/support/e2e';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Collection Page', () => {
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/collections/' + TEST_COLLECTION);
|
||||
cy.visit('/collections/'.concat(TEST_COLLECTION));
|
||||
|
||||
// <ds-collection-page> tag must be loaded
|
||||
cy.get('ds-collection-page').should('exist');
|
||||
cy.get('ds-collection-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-collection-page> for accessibility issues
|
||||
testA11y('ds-collection-page');
|
37
cypress/e2e/collection-statistics.cy.ts
Normal file
37
cypress/e2e/collection-statistics.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } 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);
|
||||
|
||||
// <ds-collection-statistics-page> 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 <ds-collection-statistics-page> for accessibility issues
|
||||
testA11y('ds-collection-statistics-page');
|
||||
});
|
||||
});
|
@@ -7,10 +7,10 @@ describe('Community List Page', () => {
|
||||
cy.visit('/community-list');
|
||||
|
||||
// <ds-community-list-page> tag must be loaded
|
||||
cy.get('ds-community-list-page').should('exist');
|
||||
cy.get('ds-community-list-page').should('be.visible');
|
||||
|
||||
// Open first Community (to show Collections)...that way we scan sub-elements as well
|
||||
cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click();
|
||||
// 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 <ds-community-list-page> for accessibility issues
|
||||
// Disable heading-order checks until it is fixed
|
@@ -1,13 +1,13 @@
|
||||
import { TEST_COMMUNITY } from 'cypress/support';
|
||||
import { TEST_COMMUNITY } from 'cypress/support/e2e';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Community Page', () => {
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/communities/' + TEST_COMMUNITY);
|
||||
cy.visit('/communities/'.concat(TEST_COMMUNITY));
|
||||
|
||||
// <ds-community-page> tag must be loaded
|
||||
cy.get('ds-community-page').should('exist');
|
||||
cy.get('ds-community-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-community-page> for accessibility issues
|
||||
testA11y('ds-community-page',);
|
37
cypress/e2e/community-statistics.cy.ts
Normal file
37
cypress/e2e/community-statistics.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } 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);
|
||||
|
||||
// <ds-community-statistics-page> 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 <ds-community-statistics-page> for accessibility issues
|
||||
testA11y('ds-community-statistics-page');
|
||||
});
|
||||
});
|
31
cypress/e2e/homepage-statistics.cy.ts
Normal file
31
cypress/e2e/homepage-statistics.cy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
import '../support/commands';
|
||||
|
||||
describe('Site Statistics Page', () => {
|
||||
it('should load if you click on "Statistics" from homepage', () => {
|
||||
cy.visit('/');
|
||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||
cy.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');
|
||||
|
||||
cy.visit('/statistics');
|
||||
|
||||
// <ds-site-statistics-page> 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);
|
||||
|
||||
// Analyze <ds-site-statistics-page> for accessibility issues
|
||||
testA11y('ds-site-statistics-page');
|
||||
});
|
||||
});
|
@@ -1,10 +1,10 @@
|
||||
import { Options } from 'cypress-axe';
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Item Page', () => {
|
||||
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
|
||||
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
||||
const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION);
|
||||
const ENTITYPAGE = '/entities/publication/'.concat(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', () => {
|
||||
@@ -16,7 +16,7 @@ describe('Item Page', () => {
|
||||
cy.visit(ENTITYPAGE);
|
||||
|
||||
// <ds-item-page> tag must be loaded
|
||||
cy.get('ds-item-page').should('exist');
|
||||
cy.get('ds-item-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-item-page> for accessibility issues
|
||||
// Disable heading-order checks until it is fixed
|
@@ -1,36 +1,41 @@
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Item Statistics Page', () => {
|
||||
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
|
||||
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/' + TEST_ENTITY_PUBLICATION);
|
||||
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('exist');
|
||||
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('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist');
|
||||
cy.get('table[data-test="TotalVisits"]').should('be.visible');
|
||||
});
|
||||
|
||||
it('should contain a "Total visits per month" section', () => {
|
||||
cy.visit(ITEMSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist');
|
||||
// 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);
|
||||
|
||||
// <ds-item-statistics-page> tag must be loaded
|
||||
cy.get('ds-item-statistics-page').should('exist');
|
||||
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 <ds-item-statistics-page> for accessibility issues
|
||||
testA11y('ds-item-statistics-page');
|
@@ -1,4 +1,4 @@
|
||||
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||
|
||||
const page = {
|
||||
openLoginMenu() {
|
||||
@@ -36,7 +36,7 @@ const page = {
|
||||
|
||||
describe('Login Modal', () => {
|
||||
it('should login when clicking button & stay on same page', () => {
|
||||
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
||||
const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
|
||||
cy.visit(ENTITYPAGE);
|
||||
|
||||
// Login menu should exist
|
@@ -1,5 +1,5 @@
|
||||
import { Options } from 'cypress-axe';
|
||||
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support';
|
||||
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', () => {
|
||||
@@ -9,7 +9,7 @@ describe('My DSpace page', () => {
|
||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||
|
||||
cy.get('ds-my-dspace-page').should('exist');
|
||||
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');
|
||||
@@ -42,12 +42,12 @@ describe('My DSpace page', () => {
|
||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||
|
||||
cy.get('ds-my-dspace-page').should('exist');
|
||||
cy.get('ds-my-dspace-page').should('be.visible');
|
||||
|
||||
// Click button in sidebar to display detailed view
|
||||
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
|
||||
|
||||
cy.get('ds-object-detail').should('exist');
|
||||
cy.get('ds-object-detail').should('be.visible');
|
||||
|
||||
// Analyze <ds-search-page> for accessibility issues
|
||||
testA11y('ds-my-dspace-page',
|
||||
@@ -80,7 +80,7 @@ describe('My DSpace page', () => {
|
||||
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
|
||||
|
||||
// Click on the button matching that known Collection name
|
||||
cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click();
|
||||
cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click();
|
||||
|
||||
// New URL should include /workspaceitems, as we've started a new submission
|
||||
cy.url().should('include', '/workspaceitems');
|
@@ -2,7 +2,7 @@ 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('exist');
|
||||
cy.get('ds-pagenotfound').should('be.visible');
|
||||
});
|
||||
|
||||
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
|
@@ -1,4 +1,4 @@
|
||||
import { TEST_SEARCH_TERM } from 'cypress/support';
|
||||
import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
|
||||
|
||||
const page = {
|
||||
fillOutQueryInNavBar(query) {
|
||||
@@ -27,7 +27,7 @@ describe('Search from Navigation Bar', () => {
|
||||
page.fillOutQueryInNavBar(query);
|
||||
page.submitQueryByPressingEnter();
|
||||
// New URL should include query param
|
||||
cy.url().should('include', 'query=' + query);
|
||||
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
|
||||
@@ -42,7 +42,7 @@ describe('Search from Navigation Bar', () => {
|
||||
page.fillOutQueryInNavBar(query);
|
||||
page.submitQueryByPressingEnter();
|
||||
// New URL should include query param
|
||||
cy.url().should('include', 'query=' + query);
|
||||
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
|
||||
@@ -57,7 +57,7 @@ describe('Search from Navigation Bar', () => {
|
||||
page.fillOutQueryInNavBar(query);
|
||||
page.submitQueryByPressingIcon();
|
||||
// New URL should include query param
|
||||
cy.url().should('include', 'query=' + query);
|
||||
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
|
@@ -1,5 +1,5 @@
|
||||
import { Options } from 'cypress-axe';
|
||||
import { TEST_SEARCH_TERM } from 'cypress/support';
|
||||
import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Search Page', () => {
|
||||
@@ -13,11 +13,11 @@ describe('Search Page', () => {
|
||||
});
|
||||
|
||||
it('should load results and pass accessibility tests', () => {
|
||||
cy.visit('/search?query=' + TEST_SEARCH_TERM);
|
||||
cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
|
||||
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
|
||||
|
||||
// <ds-search-page> tag must be loaded
|
||||
cy.get('ds-search-page').should('exist');
|
||||
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');
|
||||
@@ -45,13 +45,13 @@ describe('Search Page', () => {
|
||||
});
|
||||
|
||||
it('should have a working grid view that passes accessibility tests', () => {
|
||||
cy.visit('/search?query=' + TEST_SEARCH_TERM);
|
||||
cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
|
||||
|
||||
// Click button in sidebar to display grid view
|
||||
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
|
||||
|
||||
// <ds-search-page> tag must be loaded
|
||||
cy.get('ds-search-page').should('exist');
|
||||
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');
|
@@ -1,13 +1,11 @@
|
||||
import { Options } from 'cypress-axe';
|
||||
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e';
|
||||
|
||||
describe('New Submission page', () => {
|
||||
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts
|
||||
|
||||
it('should create a new submission when using /submit path & pass accessibility', () => {
|
||||
// Test that calling /submit with collection & entityType will create a new submission
|
||||
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
|
||||
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);
|
||||
@@ -35,7 +33,7 @@ describe('New Submission page', () => {
|
||||
|
||||
it('should block submission & show errors if required fields are missing', () => {
|
||||
// Create a new submission
|
||||
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
|
||||
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);
|
||||
@@ -95,7 +93,7 @@ describe('New Submission page', () => {
|
||||
|
||||
it('should allow for deposit if all required fields completed & file uploaded', () => {
|
||||
// Create a new submission
|
||||
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
|
||||
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);
|
||||
@@ -124,8 +122,6 @@ describe('New Submission page', () => {
|
||||
|
||||
// Wait for upload to complete before proceeding
|
||||
cy.wait('@upload');
|
||||
// Close the upload success notice
|
||||
cy.get('[data-dismiss="alert"]').click({multiple: true});
|
||||
|
||||
// Wait for deposit button to not be disabled & click it.
|
||||
cy.get('button#deposit').should('not.be.disabled').click();
|
@@ -1,32 +0,0 @@
|
||||
import { TEST_COLLECTION } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Collection Statistics Page', () => {
|
||||
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION;
|
||||
|
||||
it('should load if you click on "Statistics" from a Collection page', () => {
|
||||
cy.visit('/collections/' + 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('.' + TEST_COLLECTION + '_TotalVisits').should('exist');
|
||||
});
|
||||
|
||||
it('should contain a "Total visits per month" section', () => {
|
||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist');
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||
|
||||
// <ds-collection-statistics-page> tag must be loaded
|
||||
cy.get('ds-collection-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-collection-statistics-page> for accessibility issues
|
||||
testA11y('ds-collection-statistics-page');
|
||||
});
|
||||
});
|
@@ -1,32 +0,0 @@
|
||||
import { TEST_COMMUNITY } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Community Statistics Page', () => {
|
||||
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY;
|
||||
|
||||
it('should load if you click on "Statistics" from a Community page', () => {
|
||||
cy.visit('/communities/' + 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('.' + TEST_COMMUNITY + '_TotalVisits').should('exist');
|
||||
});
|
||||
|
||||
it('should contain a "Total visits per month" section', () => {
|
||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist');
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||
|
||||
// <ds-community-statistics-page> tag must be loaded
|
||||
cy.get('ds-community-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-community-statistics-page> for accessibility issues
|
||||
testA11y('ds-community-statistics-page');
|
||||
});
|
||||
});
|
@@ -1,19 +0,0 @@
|
||||
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 pass accessibility tests', () => {
|
||||
cy.visit('/statistics');
|
||||
|
||||
// <ds-site-statistics-page> tag must be loaded
|
||||
cy.get('ds-site-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-site-statistics-page> for accessibility issues
|
||||
testA11y('ds-site-statistics-page');
|
||||
});
|
||||
});
|
@@ -4,12 +4,17 @@
|
||||
// ***********************************************
|
||||
|
||||
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
|
||||
import { FALLBACK_TEST_REST_BASE_URL } from '.';
|
||||
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';
|
||||
|
||||
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
|
||||
// ALL custom commands MUST be listed here for code completion to work
|
||||
// tslint:disable-next-line:no-namespace
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable<Subject = any> {
|
||||
/**
|
||||
@@ -27,6 +32,15 @@ declare global {
|
||||
* @param password password to login as
|
||||
*/
|
||||
loginViaForm(email: string, password: string): typeof loginViaForm;
|
||||
|
||||
/**
|
||||
* Generate view event for given object. Useful for testing statistics pages with
|
||||
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
|
||||
* generate multiple hits.
|
||||
* @param uuid UUID of object
|
||||
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
|
||||
*/
|
||||
generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,52 +67,57 @@ function login(email: string, password: string): void {
|
||||
if (!config.rest.baseUrl) {
|
||||
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
|
||||
} else {
|
||||
console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl);
|
||||
//console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl));
|
||||
baseRestUrl = config.rest.baseUrl;
|
||||
}
|
||||
|
||||
// To login via REST, first we have to do a GET to obtain a valid CSRF token
|
||||
cy.request( baseRestUrl + '/api/authn/status' )
|
||||
.then((response) => {
|
||||
// We should receive a CSRF token returned in a response header
|
||||
expect(response.headers).to.have.property('dspace-xsrf-token');
|
||||
const csrfToken = response.headers['dspace-xsrf-token'];
|
||||
// Now 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;
|
||||
}
|
||||
|
||||
// Now, send login POST request including that CSRF token
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: baseRestUrl + '/api/authn/login',
|
||||
headers: { 'X-XSRF-TOKEN' : csrfToken},
|
||||
form: true, // indicates the body should be form urlencoded
|
||||
body: { user: email, password: password }
|
||||
}).then((resp) => {
|
||||
// We expect a successful login
|
||||
expect(resp.status).to.eq(200);
|
||||
// We expect to have a valid authorization header returned (with our auth token)
|
||||
expect(resp.headers).to.have.property('authorization');
|
||||
// Create a fake CSRF Token. Set it in the required server-side cookie
|
||||
const csrfToken = 'fakeLoginCSRFToken';
|
||||
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
|
||||
|
||||
// Initialize our AuthTokenInfo object from the authorization header.
|
||||
const authheader = resp.headers.authorization as string;
|
||||
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
|
||||
// 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');
|
||||
|
||||
// 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));
|
||||
});
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
// Add as a Cypress command (i.e. assign to 'cy.login')
|
||||
Cypress.Commands.add('login', login);
|
||||
|
||||
|
||||
/**
|
||||
* Login user via displayed login form
|
||||
* @param email email to login as
|
||||
* @param password password to login as
|
||||
*/
|
||||
function loginViaForm(email: string, password: string): void {
|
||||
function loginViaForm(email: string, password: string): void {
|
||||
// Enter email
|
||||
cy.get('ds-log-in [data-test="email"]').type(email);
|
||||
// Enter password
|
||||
@@ -107,4 +126,69 @@ Cypress.Commands.add('login', login);
|
||||
cy.get('ds-log-in [data-test="login-button"]').click();
|
||||
}
|
||||
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
|
||||
Cypress.Commands.add('loginViaForm', loginViaForm);
|
||||
Cypress.Commands.add('loginViaForm', loginViaForm);
|
||||
|
||||
|
||||
/**
|
||||
* Generate statistic view event for given object. Useful for testing statistics pages with
|
||||
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
|
||||
* generate multiple hits.
|
||||
*
|
||||
* NOTE: This requires that "solr-statistics.autoCommit=false" be set on the DSpace backend
|
||||
* (as it is in our docker-compose-ci.yml used in CI).
|
||||
* Otherwise, by default, new statistical events won't be saved until Solr's autocommit next triggers.
|
||||
* @param uuid UUID of object
|
||||
* @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);
|
||||
});
|
||||
}
|
||||
// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
|
||||
Cypress.Commands.add('generateViewEvent', generateViewEvent);
|
||||
|
||||
|
@@ -30,11 +30,11 @@ beforeEach(() => {
|
||||
// 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(() => {
|
||||
/*afterEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.location.href = 'about:blank';
|
||||
});
|
||||
});
|
||||
});*/
|
||||
|
||||
|
||||
// Global constants used in tests
|
||||
@@ -43,10 +43,6 @@ afterEach(() => {
|
||||
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
|
||||
// (This is the data set used in our CI environment)
|
||||
|
||||
// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
|
||||
// from the Angular UI's config.json. See 'getBaseRESTUrl()' in commands.ts
|
||||
export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
|
||||
|
||||
// Admin account used for administrative tests
|
||||
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
|
||||
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
|
||||
@@ -61,3 +57,10 @@ export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLE
|
||||
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';
|
||||
|
||||
|
||||
// USEFUL REGEX for testing
|
||||
|
||||
// Match any string that contains at least one non-space character
|
||||
// Can be used with "contains()" to determine if an element has a non-empty text value
|
||||
export const REGEX_MATCH_NON_EMPTY_TEXT = /^(?!\s*$).+/;
|
@@ -6,7 +6,20 @@
|
||||
If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario.
|
||||
***
|
||||
|
||||
## 'Dockerfile' in root directory
|
||||
## Overview
|
||||
The scripts in this directory can be used to start the DSpace User Interface (frontend) in Docker.
|
||||
Optionally, the backend (REST API) might also be started in Docker.
|
||||
|
||||
For additional options/settings in starting the backend (REST API) in Docker, see the Docker Compose
|
||||
documentation for the backend: https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md
|
||||
|
||||
## Root directory
|
||||
|
||||
The root directory of this project contains all the Dockerfiles which may be referenced by
|
||||
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'
|
||||
|
||||
```
|
||||
@@ -20,7 +33,18 @@ Admins to our DockerHub repo can manually publish with the following command.
|
||||
docker push dspace/dspace-angular:dspace-7_x
|
||||
```
|
||||
|
||||
## docker directory
|
||||
### Dockerfile.dist
|
||||
|
||||
The `Dockerfile.dist` is used to generate a *production* build and runtime environment.
|
||||
|
||||
```bash
|
||||
# build the latest image
|
||||
docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_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.
|
||||
- docker-compose-rest.yml
|
||||
@@ -45,23 +69,47 @@ 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
|
||||
```
|
||||
|
||||
Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section.
|
||||
|
||||
|
||||
## Run DSpace REST and DSpace Angular from local branches.
|
||||
|
||||
This section assumes that you have clones *both* the 'DSpace/DSpace' and 'DSpace/dspace-angular' GitHub
|
||||
repositories. When both are available locally, you can spin up both in Docker and have them work together.
|
||||
|
||||
_The system will be started in 2 steps. Each step shares the same docker network._
|
||||
|
||||
From DSpace/DSpace (build as needed)
|
||||
From 'DSpace/DSpace' clone (build first as needed):
|
||||
```
|
||||
docker-compose -p d7 up -d
|
||||
```
|
||||
|
||||
From DSpace/DSpace-angular
|
||||
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
|
||||
```
|
||||
|
||||
At this point, you should be able to access the UI from http://localhost:4000,
|
||||
and the backend at http://localhost:8080/server/
|
||||
|
||||
## Run DSpace Angular dist build with DSpace Demo site backend
|
||||
|
||||
This allows you to run the Angular UI in *production* mode, pointing it at the demo backend
|
||||
(https://api7.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
|
||||
```
|
||||
|
||||
## Ingest test data from AIPDIR
|
||||
|
||||
Create an administrator
|
||||
@@ -87,9 +135,10 @@ Load assetstore content and trigger a re-index of the repository
|
||||
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
|
||||
```
|
||||
|
||||
## End to end testing of the rest api (runs in travis).
|
||||
_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._
|
||||
## End to end testing of the REST API (runs in GitHub Actions CI).
|
||||
_In this instance, only the REST api runs in Docker using the Entities dataset. GitHub Actions will perform CI testing of Angular using Node to drive the tests. See `.github/workflows/build.yml` for more details._
|
||||
|
||||
This command is only really useful for testing our Continuous Integration process.
|
||||
```
|
||||
docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d
|
||||
docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d
|
||||
```
|
||||
|
@@ -30,6 +30,9 @@ services:
|
||||
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
|
||||
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
||||
solr__P__server: http://dspacesolr:8983/solr
|
||||
# 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'
|
||||
depends_on:
|
||||
- dspacedb
|
||||
image: dspace/dspace:dspace-7_x-test
|
||||
|
40
docker/docker-compose-dist.yml
Normal file
40
docker/docker-compose-dist.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
#
|
||||
# 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/
|
||||
#
|
||||
|
||||
# Docker Compose for running the DSpace Angular UI dist build
|
||||
# for previewing with the DSpace Demo site backend
|
||||
version: '3.7'
|
||||
networks:
|
||||
dspacenet:
|
||||
services:
|
||||
dspace-angular:
|
||||
container_name: dspace-angular
|
||||
environment:
|
||||
DSPACE_UI_SSL: 'false'
|
||||
DSPACE_UI_HOST: dspace-angular
|
||||
DSPACE_UI_PORT: '4000'
|
||||
DSPACE_UI_NAMESPACE: /
|
||||
# NOTE: When running the UI in production mode (which the -dist image does),
|
||||
# these DSPACE_REST_* variables MUST point at a public, HTTPS URL.
|
||||
# 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: api7.dspace.org
|
||||
DSPACE_REST_PORT: 443
|
||||
DSPACE_REST_NAMESPACE: /server
|
||||
image: dspace/dspace-angular:dspace-7_x-dist
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.dist
|
||||
networks:
|
||||
dspacenet:
|
||||
ports:
|
||||
- published: 4000
|
||||
target: 4000
|
||||
stdin_open: true
|
||||
tty: true
|
@@ -39,7 +39,7 @@ services:
|
||||
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
|
||||
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
|
||||
proxies__P__trusted__P__ipranges: '172.23.0'
|
||||
image: dspace/dspace:dspace-7_x-test
|
||||
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}"
|
||||
depends_on:
|
||||
- dspacedb
|
||||
networks:
|
||||
@@ -82,8 +82,7 @@ services:
|
||||
# DSpace Solr container
|
||||
dspacesolr:
|
||||
container_name: dspacesolr
|
||||
# Uses official Solr image at https://hub.docker.com/_/solr/
|
||||
image: solr:8.11-slim
|
||||
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
|
||||
@@ -96,28 +95,26 @@ 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 local configsets (see above), then start Solr
|
||||
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
|
||||
# * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core
|
||||
# to the latest configs. If it's a newly created core, 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`
|
||||
entrypoint:
|
||||
- /bin/bash
|
||||
- '-c'
|
||||
- |
|
||||
init-var-solr
|
||||
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
|
||||
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority
|
||||
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
|
||||
cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai
|
||||
precreate-core search /opt/solr/server/solr/configsets/dspace/search
|
||||
cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search
|
||||
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
|
||||
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* 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
|
||||
exec solr -f
|
||||
volumes:
|
||||
assetstore:
|
||||
|
11
docker/dspace-ui.json
Normal file
11
docker/dspace-ui.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "dspace-ui",
|
||||
"cwd": "/app",
|
||||
"script": "dist/server/main.js",
|
||||
"instances": "max",
|
||||
"exec_mode": "cluster"
|
||||
}
|
||||
]
|
||||
}
|
@@ -163,13 +163,14 @@
|
||||
"compression-webpack-plugin": "^9.2.0",
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"cypress": "9.7.0",
|
||||
"cypress-axe": "^0.14.0",
|
||||
"cypress": "12.9.0",
|
||||
"cypress-axe": "^1.1.0",
|
||||
"deep-freeze": "0.0.1",
|
||||
"eslint": "^8.2.0",
|
||||
"eslint-plugin-deprecation": "^1.3.2",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jsdoc": "^39.6.4",
|
||||
"eslint-plugin-jsonc": "^2.6.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"express-static-gzip": "^2.1.5",
|
||||
@@ -198,7 +199,7 @@
|
||||
"sass-resources-loader": "^2.1.1",
|
||||
"ts-node": "^8.10.2",
|
||||
"typescript": "~4.5.5",
|
||||
"webpack": "^5.69.1",
|
||||
"webpack": "^5.76.0",
|
||||
"webpack-bundle-analyzer": "^4.4.0",
|
||||
"webpack-cli": "^4.2.0",
|
||||
"webpack-dev-server": "^4.5.0"
|
||||
|
@@ -13,12 +13,13 @@
|
||||
[formGroup]="formGroup"
|
||||
[formLayout]="formLayout"
|
||||
[displayCancel]="false"
|
||||
[submitLabel]="submitLabel"
|
||||
(submitForm)="onSubmit()">
|
||||
<div before class="btn-group">
|
||||
<button (click)="onCancel()"
|
||||
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
|
||||
</div>
|
||||
<div between class="btn-group">
|
||||
<div *ngIf="displayResetPassword" between class="btn-group">
|
||||
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
|
||||
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
||||
</button>
|
||||
|
@@ -165,6 +165,15 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
isImpersonated = false;
|
||||
|
||||
/**
|
||||
* A boolean that indicate if to display EPersonForm's Rest password button
|
||||
*/
|
||||
displayResetPassword = false;
|
||||
|
||||
/**
|
||||
* A string that indicate the label of Submit button
|
||||
*/
|
||||
submitLabel = 'form.create';
|
||||
/**
|
||||
* Subscription to email field value change
|
||||
*/
|
||||
@@ -188,6 +197,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
this.epersonInitial = eperson;
|
||||
if (hasValue(eperson)) {
|
||||
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
|
||||
this.displayResetPassword = true;
|
||||
this.submitLabel = 'form.submit';
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
@@ -117,9 +117,15 @@ export class BatchImportPageComponent {
|
||||
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
|
||||
}
|
||||
} else {
|
||||
const title = this.translate.get('process.new.notification.error.title');
|
||||
const content = this.translate.get('process.new.notification.error.content');
|
||||
this.notificationsService.error(title, content);
|
||||
if (rd.statusCode === 413) {
|
||||
const title = this.translate.get('process.new.notification.error.title');
|
||||
const content = this.translate.get('process.new.notification.error.max-upload.content');
|
||||
this.notificationsService.error(title, content);
|
||||
} else {
|
||||
const title = this.translate.get('process.new.notification.error.title');
|
||||
const content = this.translate.get('process.new.notification.error.content');
|
||||
this.notificationsService.error(title, content);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -218,7 +218,8 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
|
||||
{
|
||||
path: 'statistics',
|
||||
loadChildren: () => import('./statistics-page/statistics-page-routing.module')
|
||||
.then((m) => m.StatisticsPageRoutingModule)
|
||||
.then((m) => m.StatisticsPageRoutingModule),
|
||||
canActivate: [EndUserAgreementCurrentUserGuard],
|
||||
},
|
||||
{
|
||||
path: HEALTH_PAGE_PATH,
|
||||
@@ -228,7 +229,7 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
|
||||
{
|
||||
path: ACCESS_CONTROL_MODULE_PATH,
|
||||
loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule),
|
||||
canActivate: [GroupAdministratorGuard],
|
||||
canActivate: [GroupAdministratorGuard, EndUserAgreementCurrentUserGuard],
|
||||
},
|
||||
{
|
||||
path: 'subscriptions',
|
||||
|
@@ -22,6 +22,7 @@ import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
||||
import { APP_CONFIG } from '../../../config/app-config.interface';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { SortDirection } from '../../core/cache/models/sort-options.model';
|
||||
|
||||
describe('BrowseByDatePageComponent', () => {
|
||||
let comp: BrowseByDatePageComponent;
|
||||
@@ -49,12 +50,22 @@ describe('BrowseByDatePageComponent', () => {
|
||||
]
|
||||
}
|
||||
});
|
||||
const lastItem = Object.assign(new Item(), {
|
||||
id: 'last-item-id',
|
||||
metadata: {
|
||||
'dc.date.issued': [
|
||||
{
|
||||
value: '1960-01-01'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const mockBrowseService = {
|
||||
getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]),
|
||||
getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]),
|
||||
getFirstItemFor: () => createSuccessfulRemoteDataObject$(firstItem)
|
||||
};
|
||||
const mockBrowseService = {
|
||||
getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]),
|
||||
getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]),
|
||||
getFirstItemFor: (definition: string, scope?: string, sortDirection?: SortDirection) => null
|
||||
};
|
||||
|
||||
const mockDsoService = {
|
||||
findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
|
||||
@@ -91,9 +102,14 @@ describe('BrowseByDatePageComponent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BrowseByDatePageComponent);
|
||||
const browseService = fixture.debugElement.injector.get(BrowseService);
|
||||
spyOn(browseService, 'getFirstItemFor')
|
||||
// ok to expect the default browse as first param since we just need the mock items obtained via sort direction.
|
||||
.withArgs('author', undefined, SortDirection.ASC).and.returnValue(createSuccessfulRemoteDataObject$(firstItem))
|
||||
.withArgs('author', undefined, SortDirection.DESC).and.returnValue(createSuccessfulRemoteDataObject$(lastItem));
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
route = (comp as any).route;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should initialize the list of items', () => {
|
||||
@@ -107,6 +123,7 @@ describe('BrowseByDatePageComponent', () => {
|
||||
});
|
||||
|
||||
it('should create a list of startsWith options with the current year first', () => {
|
||||
expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear());
|
||||
//expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear());
|
||||
expect(comp.startsWithOptions[0]).toEqual(1960);
|
||||
});
|
||||
});
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import { ChangeDetectorRef, Component, Inject } from '@angular/core';
|
||||
import {
|
||||
BrowseByMetadataPageComponent,
|
||||
browseParamsToOptions, getBrowseSearchOptions
|
||||
browseParamsToOptions,
|
||||
getBrowseSearchOptions
|
||||
} from '../browse-by-metadata-page/browse-by-metadata-page.component';
|
||||
import { combineLatest as observableCombineLatest } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { ActivatedRoute, Params, Router } from '@angular/router';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
@@ -16,7 +15,9 @@ import { map } from 'rxjs/operators';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { isValidDate } from '../../shared/date.util';
|
||||
import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface';
|
||||
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-browse-by-date-page',
|
||||
@@ -72,30 +73,24 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
||||
|
||||
/**
|
||||
* Update the StartsWith options
|
||||
* In this implementation, it creates a list of years starting from now, going all the way back to the earliest
|
||||
* date found on an item within this scope. The further back in time, the bigger the change in years become to avoid
|
||||
* extremely long lists with a one-year difference.
|
||||
* In this implementation, it creates a list of years starting from the most recent item or the current year, going
|
||||
* all the way back to the earliest date found on an item within this scope. The further back in time, the bigger
|
||||
* the change in years become to avoid extremely long lists with a one-year difference.
|
||||
* To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this.
|
||||
* @param definition The metadata definition to fetch the first item for
|
||||
* @param metadataKeys The metadata fields to fetch the earliest date from (expects a date field)
|
||||
* @param scope The scope under which to fetch the earliest item for
|
||||
*/
|
||||
updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) {
|
||||
const firstItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.ASC);
|
||||
const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC);
|
||||
this.subs.push(
|
||||
this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData<Item>) => {
|
||||
let lowerLimit = this.appConfig.browseBy.defaultLowerLimit;
|
||||
if (hasValue(firstItemRD.payload)) {
|
||||
const date = firstItemRD.payload.firstMetadataValue(metadataKeys);
|
||||
if (isNotEmpty(date) && isValidDate(date)) {
|
||||
const dateObj = new Date(date);
|
||||
// TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC.
|
||||
lowerLimit = isNaN(dateObj.getUTCFullYear()) ? lowerLimit : dateObj.getUTCFullYear();
|
||||
}
|
||||
}
|
||||
observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => {
|
||||
let lowerLimit = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit);
|
||||
let upperLimit = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear());
|
||||
const options = [];
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const oneYearBreak = Math.floor((currentYear - this.appConfig.browseBy.oneYearLimit) / 5) * 5;
|
||||
const fiveYearBreak = Math.floor((currentYear - this.appConfig.browseBy.fiveYearLimit) / 10) * 10;
|
||||
const oneYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5;
|
||||
const fiveYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10;
|
||||
if (lowerLimit <= fiveYearBreak) {
|
||||
lowerLimit -= 10;
|
||||
} else if (lowerLimit <= oneYearBreak) {
|
||||
@@ -103,7 +98,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
||||
} else {
|
||||
lowerLimit -= 1;
|
||||
}
|
||||
let i = currentYear;
|
||||
let i = upperLimit;
|
||||
while (i > lowerLimit) {
|
||||
options.push(i);
|
||||
if (i <= fiveYearBreak) {
|
||||
@@ -121,4 +116,24 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the year from the item metadata field or the limit.
|
||||
* @param itemRD the item remote data
|
||||
* @param metadataKeys The metadata fields to fetch the earliest date from (expects a date field)
|
||||
* @param limit the limit to use if the year can't be found in metadata
|
||||
* @private
|
||||
*/
|
||||
private getLimit(itemRD: RemoteData<Item>, metadataKeys: string[], limit: number): number {
|
||||
if (hasValue(itemRD.payload)) {
|
||||
const date = itemRD.payload.firstMetadataValue(metadataKeys);
|
||||
if (isNotEmpty(date) && isValidDate(date)) {
|
||||
const dateObj = new Date(date);
|
||||
// TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC.
|
||||
return isNaN(dateObj.getUTCFullYear()) ? limit : dateObj.getUTCFullYear();
|
||||
} else {
|
||||
return new Date().getUTCFullYear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -151,8 +151,17 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
|
||||
).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => {
|
||||
this.browseId = params.id || this.defaultBrowseId;
|
||||
this.authority = params.authority;
|
||||
this.value = +params.value || params.value || '';
|
||||
this.startsWith = +params.startsWith || params.startsWith;
|
||||
|
||||
if (typeof params.value === 'string'){
|
||||
this.value = params.value.trim();
|
||||
} else {
|
||||
this.value = '';
|
||||
}
|
||||
|
||||
if (typeof params.startsWith === 'string'){
|
||||
this.startsWith = params.startsWith.trim();
|
||||
}
|
||||
|
||||
if (isNotEmpty(this.value)) {
|
||||
this.updatePageWithItems(
|
||||
browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority);
|
||||
@@ -305,7 +314,7 @@ export function browseParamsToOptions(params: any,
|
||||
metadata,
|
||||
paginationConfig,
|
||||
sortConfig,
|
||||
+params.startsWith || params.startsWith,
|
||||
params.startsWith,
|
||||
params.scope,
|
||||
fetchThumbnail
|
||||
);
|
||||
|
@@ -28,7 +28,8 @@
|
||||
[title]="'toggle ' + node.name"
|
||||
[attr.aria-label]="'toggle ' + node.name"
|
||||
(click)="toggleExpanded(node)"
|
||||
[ngClass]="(hasChild(null, node)| async) ? 'visible' : 'invisible'">
|
||||
[ngClass]="(hasChild(null, node)| async) ? 'visible' : 'invisible'"
|
||||
[attr.data-test]="(hasChild(null, node)| async) ? 'expand-button' : ''">
|
||||
<span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}"
|
||||
aria-hidden="true"></span>
|
||||
</button>
|
||||
|
@@ -17,6 +17,7 @@ export const AuthActionTypes = {
|
||||
AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'),
|
||||
CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'),
|
||||
CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'),
|
||||
SET_AUTH_COOKIE_STATUS: type('dspace/auth/SET_AUTH_COOKIE_STATUS'),
|
||||
RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'),
|
||||
RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'),
|
||||
RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'),
|
||||
@@ -150,6 +151,19 @@ export class CheckAuthenticationTokenCookieAction implements Action {
|
||||
public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the authentication cookie status to flag an external authentication response.
|
||||
*/
|
||||
export class SetAuthCookieStatus implements Action {
|
||||
public type: string = AuthActionTypes.SET_AUTH_COOKIE_STATUS;
|
||||
|
||||
payload = false;
|
||||
|
||||
constructor(exists: boolean) {
|
||||
this.payload = exists;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out.
|
||||
* @class LogOutAction
|
||||
@@ -425,6 +439,7 @@ export type AuthActions
|
||||
| AuthenticationSuccessAction
|
||||
| CheckAuthenticationTokenAction
|
||||
| CheckAuthenticationTokenCookieAction
|
||||
| SetAuthCookieStatus
|
||||
| RedirectWhenAuthenticationIsRequiredAction
|
||||
| RedirectWhenTokenExpiredAction
|
||||
| AddAuthenticationMessageAction
|
||||
|
@@ -214,12 +214,15 @@ describe('AuthEffects', () => {
|
||||
authenticated: true
|
||||
})
|
||||
);
|
||||
spyOn((authEffects as any).authService, 'setExternalAuthStatus');
|
||||
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveTokenAction() });
|
||||
|
||||
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
|
||||
authEffects.checkTokenCookie$.subscribe(() => {
|
||||
expect(authServiceStub.setExternalAuthStatus).toHaveBeenCalled();
|
||||
expect(authServiceStub.isExternalAuthentication).toBeTrue();
|
||||
expect((authEffects as any).authorizationsService.invalidateAuthorizationsRequestCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@@ -153,6 +153,7 @@ export class AuthEffects {
|
||||
return this.authService.checkAuthenticationCookie().pipe(
|
||||
map((response: AuthStatus) => {
|
||||
if (response.authenticated) {
|
||||
this.authService.setExternalAuthStatus(true);
|
||||
this.authorizationsService.invalidateAuthorizationsRequestCache();
|
||||
return new RetrieveTokenAction();
|
||||
} else {
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
AuthenticationErrorAction,
|
||||
AuthenticationSuccessAction,
|
||||
CheckAuthenticationTokenAction,
|
||||
SetAuthCookieStatus,
|
||||
CheckAuthenticationTokenCookieAction,
|
||||
LogOutAction,
|
||||
LogOutErrorAction,
|
||||
@@ -219,6 +220,28 @@ describe('authReducer', () => {
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should set the authentication cookie status in response to a SET_AUTH_COOKIE_STATUS action', () => {
|
||||
initialState = {
|
||||
authenticated: true,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: true,
|
||||
externalAuth: false,
|
||||
idle: false
|
||||
};
|
||||
const action = new SetAuthCookieStatus(true);
|
||||
const newState = authReducer(initialState, action);
|
||||
state = {
|
||||
authenticated: true,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: true,
|
||||
externalAuth: true,
|
||||
idle: false
|
||||
};
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should properly set the state, in response to a LOG_OUT action', () => {
|
||||
initialState = {
|
||||
authenticated: true,
|
||||
|
@@ -10,7 +10,7 @@ import {
|
||||
RedirectWhenTokenExpiredAction,
|
||||
RefreshTokenSuccessAction,
|
||||
RetrieveAuthenticatedEpersonSuccessAction,
|
||||
RetrieveAuthMethodsSuccessAction,
|
||||
RetrieveAuthMethodsSuccessAction, SetAuthCookieStatus,
|
||||
SetRedirectUrlAction
|
||||
} from './auth.actions';
|
||||
// import models
|
||||
@@ -59,6 +59,8 @@ export interface AuthState {
|
||||
// all authentication Methods enabled at the backend
|
||||
authMethods?: AuthMethod[];
|
||||
|
||||
externalAuth?: boolean,
|
||||
|
||||
// true when the current user is idle
|
||||
idle: boolean;
|
||||
|
||||
@@ -73,6 +75,7 @@ const initialState: AuthState = {
|
||||
blocking: true,
|
||||
loading: false,
|
||||
authMethods: [],
|
||||
externalAuth: false,
|
||||
idle: false
|
||||
};
|
||||
|
||||
@@ -104,6 +107,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
loading: true,
|
||||
});
|
||||
|
||||
case AuthActionTypes.SET_AUTH_COOKIE_STATUS:
|
||||
return Object.assign({}, state, {
|
||||
externalAuth: (action as SetAuthCookieStatus).payload
|
||||
});
|
||||
|
||||
case AuthActionTypes.AUTHENTICATED_ERROR:
|
||||
case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_ERROR:
|
||||
return Object.assign({}, state, {
|
||||
|
@@ -25,7 +25,7 @@ import {
|
||||
import { CookieService } from '../services/cookie.service';
|
||||
import {
|
||||
getAuthenticatedUserId,
|
||||
getAuthenticationToken,
|
||||
getAuthenticationToken, getExternalAuthCookieStatus,
|
||||
getRedirectUrl,
|
||||
isAuthenticated,
|
||||
isAuthenticatedLoaded,
|
||||
@@ -36,7 +36,7 @@ import { AppState } from '../../app.reducer';
|
||||
import {
|
||||
CheckAuthenticationTokenAction,
|
||||
RefreshTokenAction,
|
||||
ResetAuthenticationMessagesAction,
|
||||
ResetAuthenticationMessagesAction, SetAuthCookieStatus,
|
||||
SetRedirectUrlAction,
|
||||
SetUserAsIdleAction,
|
||||
UnsetUserAsIdleAction
|
||||
@@ -156,6 +156,24 @@ export class AuthService {
|
||||
return this.store.pipe(select(isAuthenticatedLoaded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the external authentication status when authenticating via an
|
||||
* external authentication system (e.g. Shibboleth).
|
||||
* @param external
|
||||
*/
|
||||
public setExternalAuthStatus(external: boolean) {
|
||||
this.store.dispatch(new SetAuthCookieStatus(external));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an external authentication system (e.g. Shibboleth) is being used
|
||||
* for authentication. Returns false otherwise.
|
||||
*/
|
||||
public isExternalAuthentication(): Observable<boolean> {
|
||||
return this.store.pipe(
|
||||
select(getExternalAuthCookieStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the href link to authenticated user
|
||||
* @returns {string}
|
||||
|
@@ -116,6 +116,8 @@ const _getRedirectUrl = (state: AuthState) => state.redirectUrl;
|
||||
|
||||
const _getAuthenticationMethods = (state: AuthState) => state.authMethods;
|
||||
|
||||
const _getExternalAuthCookieStatus = (state: AuthState) => state.externalAuth;
|
||||
|
||||
/**
|
||||
* Returns true if the user is idle.
|
||||
* @function _isIdle
|
||||
@@ -178,6 +180,16 @@ export const isAuthenticated = createSelector(getAuthState, _isAuthenticated);
|
||||
*/
|
||||
export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticatedLoaded);
|
||||
|
||||
/**
|
||||
* Returns the authentication cookie status. Expect to be true when external authentication
|
||||
* is used.
|
||||
* @function getExternalAuthCookieStatus
|
||||
* @param {AuthState} state
|
||||
* @param {any} props
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const getExternalAuthCookieStatus = createSelector(getAuthState, _getExternalAuthCookieStatus);
|
||||
|
||||
/**
|
||||
* Returns true if the authentication request is loading.
|
||||
* @function isAuthenticationLoading
|
||||
|
@@ -8,7 +8,7 @@ import { PostRequest } from '../data/request.models';
|
||||
import {
|
||||
XSRF_REQUEST_HEADER,
|
||||
XSRF_RESPONSE_HEADER
|
||||
} from '../xsrf/xsrf.interceptor';
|
||||
} from '../xsrf/xsrf.constants';
|
||||
|
||||
describe(`ServerAuthRequestService`, () => {
|
||||
let href: string;
|
||||
|
@@ -13,7 +13,7 @@ import {
|
||||
XSRF_REQUEST_HEADER,
|
||||
XSRF_RESPONSE_HEADER,
|
||||
DSPACE_XSRF_COOKIE
|
||||
} from '../xsrf/xsrf.interceptor';
|
||||
} from '../xsrf/xsrf.constants';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
|
@@ -22,6 +22,7 @@ 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';
|
||||
|
||||
|
||||
export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
|
||||
@@ -160,8 +161,9 @@ export class BrowseService {
|
||||
* Get the first item for a metadata definition in an optional scope
|
||||
* @param definition
|
||||
* @param scope
|
||||
* @param sortDirection optional sort parameter
|
||||
*/
|
||||
getFirstItemFor(definition: string, scope?: string): Observable<RemoteData<Item>> {
|
||||
getFirstItemFor(definition: string, scope?: string, sortDirection?: SortDirection): Observable<RemoteData<Item>> {
|
||||
const href$ = this.getBrowseDefinitions().pipe(
|
||||
getBrowseDefinitionLinks(definition),
|
||||
hasValueOperator(),
|
||||
@@ -177,6 +179,9 @@ export class BrowseService {
|
||||
}
|
||||
args.push('page=0');
|
||||
args.push('size=1');
|
||||
if (sortDirection) {
|
||||
args.push('sort=default,' + sortDirection);
|
||||
}
|
||||
if (isNotEmpty(args)) {
|
||||
href = new URLCombiner(href, `?${args.join('&')}`).toString();
|
||||
}
|
||||
|
@@ -176,6 +176,7 @@ import { VocabularyEntryDetailsDataService } from './submission/vocabularies/voc
|
||||
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';
|
||||
|
||||
/**
|
||||
* When not in production, endpoint responses can be mocked for testing purposes
|
||||
@@ -369,6 +370,7 @@ export const models =
|
||||
AccessStatusObject,
|
||||
IdentifierData,
|
||||
Subscription,
|
||||
ItemRequest,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -246,10 +246,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
|
||||
* Get the endpoint to move the item
|
||||
* @param itemId
|
||||
*/
|
||||
public getMoveItemEndpoint(itemId: string): Observable<string> {
|
||||
public getMoveItemEndpoint(itemId: string, inheritPolicies: boolean): Observable<string> {
|
||||
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||
map((endpoint: string) => this.getIDHref(endpoint, itemId)),
|
||||
map((endpoint: string) => `${endpoint}/owningCollection`),
|
||||
map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,14 +258,14 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
|
||||
* @param itemId
|
||||
* @param collection
|
||||
*/
|
||||
public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<any>> {
|
||||
public moveToCollection(itemId: string, collection: Collection, inheritPolicies: boolean): Observable<RemoteData<any>> {
|
||||
const options: HttpOptions = Object.create({});
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.append('Content-Type', 'text/uri-list');
|
||||
options.headers = headers;
|
||||
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
const hrefObs = this.getMoveItemEndpoint(itemId);
|
||||
const hrefObs = this.getMoveItemEndpoint(itemId, inheritPolicies);
|
||||
|
||||
hrefObs.pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
|
@@ -62,25 +62,33 @@ describe('LocaleService test suite', () => {
|
||||
});
|
||||
|
||||
describe('getCurrentLanguageCode', () => {
|
||||
it('should return language saved on cookie', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(translateService, 'getLangs').and.returnValue(langList);
|
||||
});
|
||||
|
||||
it('should return the language saved on cookie if it\'s a valid & active language', () => {
|
||||
spyOnGet.and.returnValue('de');
|
||||
expect(service.getCurrentLanguageCode()).toBe('de');
|
||||
});
|
||||
|
||||
describe('', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(translateService, 'getLangs').and.returnValue(langList);
|
||||
});
|
||||
it('should return the default language if the cookie language is disabled', () => {
|
||||
spyOnGet.and.returnValue('disabled');
|
||||
expect(service.getCurrentLanguageCode()).toBe('en');
|
||||
});
|
||||
|
||||
it('should return language from browser setting', () => {
|
||||
spyOn(translateService, 'getBrowserLang').and.returnValue('it');
|
||||
expect(service.getCurrentLanguageCode()).toBe('it');
|
||||
});
|
||||
it('should return the default language if the cookie language does not exist', () => {
|
||||
spyOnGet.and.returnValue('does-not-exist');
|
||||
expect(service.getCurrentLanguageCode()).toBe('en');
|
||||
});
|
||||
|
||||
it('should return default language from config', () => {
|
||||
spyOn(translateService, 'getBrowserLang').and.returnValue('fr');
|
||||
expect(service.getCurrentLanguageCode()).toBe('en');
|
||||
});
|
||||
it('should return language from browser setting', () => {
|
||||
spyOn(translateService, 'getBrowserLang').and.returnValue('it');
|
||||
expect(service.getCurrentLanguageCode()).toBe('it');
|
||||
});
|
||||
|
||||
it('should return default language from config', () => {
|
||||
spyOn(translateService, 'getBrowserLang').and.returnValue('fr');
|
||||
expect(service.getCurrentLanguageCode()).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -11,6 +11,7 @@ import { map, mergeMap, take } from 'rxjs/operators';
|
||||
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||
import { RouteService } from '../services/route.service';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { LangConfig } from '../../../config/lang-config.interface';
|
||||
|
||||
export const LANG_COOKIE = 'dsLanguage';
|
||||
|
||||
@@ -52,8 +53,7 @@ export class LocaleService {
|
||||
getCurrentLanguageCode(): string {
|
||||
// Attempt to get the language from a cookie
|
||||
let lang = this.getLanguageCodeFromCookie();
|
||||
if (isEmpty(lang)) {
|
||||
// Cookie not found
|
||||
if (isEmpty(lang) || environment.languages.find((langConfig: LangConfig) => langConfig.code === lang && langConfig.active) === undefined) {
|
||||
// Attempt to get the browser language from the user
|
||||
if (this.translate.getLangs().includes(this.translate.getBrowserLang())) {
|
||||
lang = this.translate.getBrowserLang();
|
||||
|
33
src/app/core/xsrf/xsrf.constants.ts
Normal file
33
src/app/core/xsrf/xsrf.constants.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* XSRF / CSRF related constants
|
||||
*/
|
||||
|
||||
/**
|
||||
* Name of CSRF/XSRF header we (client) may SEND in requests to backend.
|
||||
* (This is a standard header name for XSRF/CSRF defined by Angular)
|
||||
*/
|
||||
export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN';
|
||||
|
||||
/**
|
||||
* Name of CSRF/XSRF header we (client) may RECEIVE in responses from backend
|
||||
* This header is defined by DSpace backend, see https://github.com/DSpace/RestContract/blob/main/csrf-tokens.md
|
||||
*/
|
||||
export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN';
|
||||
|
||||
/**
|
||||
* Name of client-side Cookie where we store the CSRF/XSRF token between requests.
|
||||
* This cookie is only available to client, and should be updated whenever a new XSRF_RESPONSE_HEADER
|
||||
* is found in a response from the backend.
|
||||
*/
|
||||
export const XSRF_COOKIE = 'XSRF-TOKEN';
|
||||
|
||||
/**
|
||||
* Name of server-side cookie the backend expects the XSRF token to be in.
|
||||
* When the backend receives a modifying request, it will validate the CSRF/XSRF token by looking
|
||||
* for a match between the XSRF_REQUEST_HEADER and this Cookie. For more details see
|
||||
* https://github.com/DSpace/RestContract/blob/main/csrf-tokens.md
|
||||
*
|
||||
* NOTE: This Cookie is NOT readable to the client/UI. It is only readable to the backend and will
|
||||
* be sent along automatically by the user's browser.
|
||||
*/
|
||||
export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE';
|
@@ -12,15 +12,7 @@ import { Observable, throwError } from 'rxjs';
|
||||
import { tap, catchError } from 'rxjs/operators';
|
||||
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
|
||||
import { CookieService } from '../services/cookie.service';
|
||||
|
||||
// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular)
|
||||
export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN';
|
||||
// Name of XSRF header we may receive in responses from backend
|
||||
export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN';
|
||||
// Name of cookie where we store the XSRF token
|
||||
export const XSRF_COOKIE = 'XSRF-TOKEN';
|
||||
// Name of cookie the backend expects the XSRF token to be in
|
||||
export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE';
|
||||
import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from './xsrf.constants';
|
||||
|
||||
/**
|
||||
* Custom Http Interceptor intercepting Http Requests & Responses to
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field class="mr-auto" [item]="object">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field class="mr-auto" [item]="object">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -7,7 +7,7 @@ import { FooterComponent } from './footer.component';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-footer',
|
||||
styleUrls: ['footer.component.scss'],
|
||||
styleUrls: [],
|
||||
templateUrl: '../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedFooterComponent extends ThemedComponent<FooterComponent> {
|
||||
@@ -20,6 +20,6 @@ export class ThemedFooterComponent extends ThemedComponent<FooterComponent> {
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./footer.component`);
|
||||
return import('./footer.component');
|
||||
}
|
||||
}
|
||||
|
@@ -3,11 +3,11 @@ import { ThemedComponent } from '../shared/theme-support/themed.component';
|
||||
import { HeaderNavbarWrapperComponent } from './header-navbar-wrapper.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for BreadcrumbsComponent
|
||||
* Themed wrapper for {@link HeaderNavbarWrapperComponent}
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-header-navbar-wrapper',
|
||||
styleUrls: ['./themed-header-navbar-wrapper.component.scss'],
|
||||
styleUrls: [],
|
||||
templateUrl: '../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedHeaderNavbarWrapperComponent extends ThemedComponent<HeaderNavbarWrapperComponent> {
|
||||
@@ -20,6 +20,6 @@ export class ThemedHeaderNavbarWrapperComponent extends ThemedComponent<HeaderNa
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./header-navbar-wrapper.component`);
|
||||
return import('./header-navbar-wrapper.component');
|
||||
}
|
||||
}
|
||||
|
@@ -8,8 +8,6 @@ import { Component } from '@angular/core';
|
||||
templateUrl: '../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedHomePageComponent extends ThemedComponent<HomePageComponent> {
|
||||
protected inAndOutputNames: (keyof HomePageComponent & keyof this)[];
|
||||
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'HomePageComponent';
|
||||
|
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ThemedComponent } from '../../../shared/theme-support/themed.component';
|
||||
import { FeedbackFormComponent } from './feedback-form.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for {@link FeedbackFormComponent}
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-feedback-form',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedFeedbackFormComponent extends ThemedComponent<FeedbackFormComponent> {
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'FeedbackFormComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../themes/${themeName}/app/info/feedback/feedback-form/feedback-form.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./feedback-form.component');
|
||||
}
|
||||
|
||||
}
|
@@ -1,3 +1,3 @@
|
||||
<div class="container">
|
||||
<ds-feedback-form></ds-feedback-form>
|
||||
</div>
|
||||
<ds-themed-feedback-form></ds-themed-feedback-form>
|
||||
</div>
|
||||
|
@@ -10,6 +10,7 @@ import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end
|
||||
import { ThemedPrivacyComponent } from './privacy/themed-privacy.component';
|
||||
import { FeedbackComponent } from './feedback/feedback.component';
|
||||
import { FeedbackFormComponent } from './feedback/feedback-form/feedback-form.component';
|
||||
import { ThemedFeedbackFormComponent } from './feedback/feedback-form/themed-feedback-form.component';
|
||||
import { ThemedFeedbackComponent } from './feedback/themed-feedback.component';
|
||||
import { FeedbackGuard } from '../core/feedback/feedback.guard';
|
||||
|
||||
@@ -23,6 +24,7 @@ const DECLARATIONS = [
|
||||
ThemedPrivacyComponent,
|
||||
FeedbackComponent,
|
||||
FeedbackFormComponent,
|
||||
ThemedFeedbackFormComponent,
|
||||
ThemedFeedbackComponent
|
||||
];
|
||||
|
||||
|
@@ -134,9 +134,10 @@ describe('ItemMoveComponent', () => {
|
||||
});
|
||||
comp.selectedCollectionName = 'selected-collection-id';
|
||||
comp.selectedCollection = collection1;
|
||||
comp.inheritPolicies = false;
|
||||
comp.moveToCollection();
|
||||
|
||||
expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1);
|
||||
expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1, false);
|
||||
});
|
||||
it('should call notificationsService success message on success', () => {
|
||||
comp.moveToCollection();
|
||||
|
@@ -104,7 +104,7 @@ export class ItemMoveComponent implements OnInit {
|
||||
*/
|
||||
moveToCollection() {
|
||||
this.processing = true;
|
||||
const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection)
|
||||
const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection, this.inheritPolicies)
|
||||
.pipe(getFirstCompletedRemoteData());
|
||||
|
||||
move$.subscribe((response: RemoteData<any>) => {
|
||||
|
@@ -6,9 +6,8 @@
|
||||
<ds-view-tracker [object]="item"></ds-view-tracker>
|
||||
<div *ngIf="!item.isWithdrawn || (isAdmin$|async)" class="full-item-info">
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field class="mr-auto" [item]="item"></ds-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
|
||||
<ds-themed-item-page-title-field class="mr-auto" [item]="item"></ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="simple-view-link my-3" *ngIf="!fromSubmissionObject">
|
||||
<a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]">
|
||||
|
@@ -34,8 +34,11 @@ import { ResearchEntitiesModule } from '../entity-groups/research-entities/resea
|
||||
import { ThemedItemPageComponent } from './simple/themed-item-page.component';
|
||||
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
|
||||
import { MediaViewerComponent } from './media-viewer/media-viewer.component';
|
||||
import { ThemedMediaViewerComponent } from './media-viewer/themed-media-viewer.component';
|
||||
import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component';
|
||||
import { ThemedMediaViewerVideoComponent } from './media-viewer/media-viewer-video/themed-media-viewer-video.component';
|
||||
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
|
||||
import { ThemedMediaViewerImageComponent } from './media-viewer/media-viewer-image/themed-media-viewer-image.component';
|
||||
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
||||
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
|
||||
import { VersionPageComponent } from './version-page/version-page/version-page.component';
|
||||
@@ -58,7 +61,6 @@ import {
|
||||
ThemedFullFileSectionComponent
|
||||
} from './full/field-components/file-section/themed-full-file-section.component';
|
||||
|
||||
|
||||
const ENTRY_COMPONENTS = [
|
||||
// put only entry components that use custom decorator
|
||||
PublicationComponent,
|
||||
@@ -87,8 +89,11 @@ const DECLARATIONS = [
|
||||
UploadBitstreamComponent,
|
||||
AbstractIncrementalListComponent,
|
||||
MediaViewerComponent,
|
||||
ThemedMediaViewerComponent,
|
||||
MediaViewerVideoComponent,
|
||||
ThemedMediaViewerVideoComponent,
|
||||
MediaViewerImageComponent,
|
||||
ThemedMediaViewerImageComponent,
|
||||
MiradorViewerComponent,
|
||||
VersionPageComponent,
|
||||
OrcidPageComponent,
|
||||
|
@@ -1,6 +1,20 @@
|
||||
.ngx-gallery {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
width: 340px !important;
|
||||
height: 279px !important;
|
||||
:host ::ng-deep {
|
||||
.ngx-gallery {
|
||||
width: unset !important;
|
||||
height: unset !important;
|
||||
}
|
||||
|
||||
ngx-gallery-image {
|
||||
max-width: 340px !important;
|
||||
|
||||
.ngx-gallery-image {
|
||||
background-position: left;
|
||||
}
|
||||
}
|
||||
|
||||
ngx-gallery-image:after {
|
||||
padding-top: 75%;
|
||||
display: block;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||
import { NgxGalleryImage, NgxGalleryOptions } from '@kolkov/ngx-gallery';
|
||||
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
|
||||
import { NgxGalleryAnimation } from '@kolkov/ngx-gallery';
|
||||
@@ -13,28 +13,28 @@ import { AuthService } from '../../../core/auth/auth.service';
|
||||
templateUrl: './media-viewer-image.component.html',
|
||||
styleUrls: ['./media-viewer-image.component.scss'],
|
||||
})
|
||||
export class MediaViewerImageComponent implements OnInit {
|
||||
export class MediaViewerImageComponent implements OnChanges, OnInit {
|
||||
@Input() images: MediaViewerItem[];
|
||||
@Input() preview?: boolean;
|
||||
@Input() image?: string;
|
||||
|
||||
loggedin: boolean;
|
||||
thumbnailPlaceholder = './assets/images/replacement_image.svg';
|
||||
|
||||
galleryOptions: NgxGalleryOptions[];
|
||||
galleryImages: NgxGalleryImage[];
|
||||
galleryOptions: NgxGalleryOptions[] = [];
|
||||
|
||||
galleryImages: NgxGalleryImage[] = [];
|
||||
|
||||
/**
|
||||
* Whether or not the current user is authenticated
|
||||
*/
|
||||
isAuthenticated$: Observable<boolean>;
|
||||
|
||||
constructor(private authService: AuthService) {}
|
||||
constructor(
|
||||
protected authService: AuthService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Thi method sets up the gallery settings and data
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.isAuthenticated$ = this.authService.isAuthenticated();
|
||||
ngOnChanges(): void {
|
||||
this.galleryOptions = [
|
||||
{
|
||||
preview: this.preview !== undefined ? this.preview : true,
|
||||
@@ -50,7 +50,6 @@ export class MediaViewerImageComponent implements OnInit {
|
||||
previewFullscreen: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (this.image) {
|
||||
this.galleryImages = [
|
||||
{
|
||||
@@ -64,25 +63,30 @@ export class MediaViewerImageComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isAuthenticated$ = this.authService.isAuthenticated();
|
||||
this.ngOnChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method convert an array of MediaViewerItem into NgxGalleryImage array
|
||||
* @param medias input NgxGalleryImage array
|
||||
*/
|
||||
convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] {
|
||||
const mappadImages = [];
|
||||
const mappedImages = [];
|
||||
for (const image of medias) {
|
||||
if (image.format === 'image') {
|
||||
mappadImages.push({
|
||||
mappedImages.push({
|
||||
small: image.thumbnail
|
||||
? image.thumbnail
|
||||
: './assets/images/replacement_image.svg',
|
||||
: this.thumbnailPlaceholder,
|
||||
medium: image.thumbnail
|
||||
? image.thumbnail
|
||||
: './assets/images/replacement_image.svg',
|
||||
: this.thumbnailPlaceholder,
|
||||
big: image.bitstream._links.content.href,
|
||||
});
|
||||
}
|
||||
}
|
||||
return mappadImages;
|
||||
return mappedImages;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,38 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { ThemedComponent } from '../../../shared/theme-support/themed.component';
|
||||
import { MediaViewerImageComponent } from './media-viewer-image.component';
|
||||
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
|
||||
|
||||
/**
|
||||
* Themed wrapper for {@link MediaViewerImageComponent}.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-media-viewer-image',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedMediaViewerImageComponent extends ThemedComponent<MediaViewerImageComponent> {
|
||||
|
||||
@Input() images: MediaViewerItem[];
|
||||
@Input() preview?: boolean;
|
||||
@Input() image?: string;
|
||||
|
||||
protected inAndOutputNames: (keyof MediaViewerImageComponent & keyof this)[] = [
|
||||
'images',
|
||||
'preview',
|
||||
'image',
|
||||
];
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'MediaViewerImageComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./media-viewer-image.component');
|
||||
}
|
||||
|
||||
}
|
@@ -1,23 +1,22 @@
|
||||
<video
|
||||
crossorigin="anonymous"
|
||||
#media
|
||||
[src]="filteredMedias[currentIndex].bitstream._links.content.href"
|
||||
[src]="medias[currentIndex].bitstream._links.content.href"
|
||||
id="singleVideo"
|
||||
[poster]="
|
||||
filteredMedias[currentIndex].thumbnail ||
|
||||
replacements[filteredMedias[currentIndex].format]
|
||||
medias[currentIndex].thumbnail ||
|
||||
replacements[medias[currentIndex].format]
|
||||
"
|
||||
preload="none"
|
||||
controls
|
||||
>
|
||||
<ng-container *ngIf="getMediaCap(filteredMedias[currentIndex].bitstream.name) as capInfos">
|
||||
<ng-container *ngIf="getMediaCap(medias[currentIndex].bitstream.name, captions) as capInfos">
|
||||
<ng-container *ngFor="let capInfo of capInfos">
|
||||
<track [src]="capInfo.src" [label]="capInfo.langLabel" [srclang]="capInfo.srclang" />
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</video>
|
||||
<div class="buttons" *ngIf="filteredMedias?.length > 1">
|
||||
<div class="buttons" *ngIf="medias?.length > 1">
|
||||
<button
|
||||
class="btn btn-primary previous"
|
||||
[disabled]="currentIndex === 0"
|
||||
@@ -28,7 +27,7 @@
|
||||
|
||||
<button
|
||||
class="btn btn-primary next"
|
||||
[disabled]="currentIndex === filteredMedias.length - 1"
|
||||
[disabled]="currentIndex === medias.length - 1"
|
||||
(click)="nextMedia()"
|
||||
>
|
||||
{{ "media-viewer.next" | translate }}
|
||||
@@ -44,7 +43,7 @@
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||
<button
|
||||
ngbDropdownItem
|
||||
*ngFor="let item of filteredMedias; index as indexOfelement"
|
||||
*ngFor="let item of medias; index as indexOfelement"
|
||||
class="list-element"
|
||||
(click)="selectedMedia(indexOfelement)"
|
||||
>
|
||||
|
@@ -1,4 +1,10 @@
|
||||
video {
|
||||
width: 340px;
|
||||
height: 279px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
}
|
||||
|
@@ -83,7 +83,6 @@ describe('MediaViewerVideoComponent', () => {
|
||||
fixture = TestBed.createComponent(MediaViewerVideoComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.medias = mockMediaViewerItem;
|
||||
component.filteredMedias = mockMediaViewerItem;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -94,7 +93,6 @@ describe('MediaViewerVideoComponent', () => {
|
||||
describe('should show controller buttons when the having mode then one video', () => {
|
||||
beforeEach(() => {
|
||||
component.medias = mockMediaViewerItems;
|
||||
component.filteredMedias = mockMediaViewerItems;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
@@ -1,22 +1,24 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
|
||||
import { languageHelper } from './language-helper';
|
||||
import { CaptionInfo} from './caption-info';
|
||||
import { CaptionInfo } from './caption-info';
|
||||
import { Bitstream } from 'src/app/core/shared/bitstream.model';
|
||||
|
||||
/**
|
||||
* This componenet renders a video viewer and playlist for the media viewer
|
||||
* This component renders a video viewer and playlist for the media viewer
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-media-viewer-video',
|
||||
templateUrl: './media-viewer-video.component.html',
|
||||
styleUrls: ['./media-viewer-video.component.scss'],
|
||||
})
|
||||
export class MediaViewerVideoComponent implements OnInit {
|
||||
export class MediaViewerVideoComponent {
|
||||
@Input() medias: MediaViewerItem[];
|
||||
|
||||
filteredMedias: MediaViewerItem[];
|
||||
@Input() captions: Bitstream[] = [];
|
||||
|
||||
isCollapsed = false;
|
||||
|
||||
isCollapsed: boolean;
|
||||
currentIndex = 0;
|
||||
|
||||
replacements = {
|
||||
@@ -24,13 +26,6 @@ export class MediaViewerVideoComponent implements OnInit {
|
||||
audio: './assets/images/replacement_audio.svg',
|
||||
};
|
||||
|
||||
replacementThumbnail: string;
|
||||
|
||||
ngOnInit() {
|
||||
this.isCollapsed = false;
|
||||
this.filteredMedias = this.medias.filter((media) => media.format === 'audio' || media.format === 'video');
|
||||
}
|
||||
|
||||
/**
|
||||
* This method check if there is caption file for the media
|
||||
* The caption file name is the media name plus "-" following two letter
|
||||
@@ -41,29 +36,24 @@ export class MediaViewerVideoComponent implements OnInit {
|
||||
* Two letter language code reference
|
||||
* https://www.w3schools.com/tags/ref_language_codes.asp
|
||||
*/
|
||||
getMediaCap(name: string): CaptionInfo[] {
|
||||
let filteredCapMedias: MediaViewerItem[];
|
||||
let capInfos: CaptionInfo[] = [];
|
||||
filteredCapMedias = this.medias
|
||||
.filter((media) => media.mimetype === 'text/vtt')
|
||||
.filter((media) => media.bitstream.name.substring(0, (media.bitstream.name.length - 7) ).toLowerCase() === name.toLowerCase());
|
||||
getMediaCap(name: string, captions: Bitstream[]): CaptionInfo[] {
|
||||
const capInfos: CaptionInfo[] = [];
|
||||
const filteredCapMedias: Bitstream[] = captions
|
||||
.filter((media: Bitstream) => media.name.substring(0, (media.name.length - 7)).toLowerCase() === name.toLowerCase());
|
||||
|
||||
if (filteredCapMedias) {
|
||||
filteredCapMedias
|
||||
.forEach((media, index) => {
|
||||
let srclang: string = media.bitstream.name.slice(-6, -4).toLowerCase();
|
||||
capInfos.push(new CaptionInfo(
|
||||
media.bitstream._links.content.href,
|
||||
srclang,
|
||||
languageHelper[srclang]
|
||||
));
|
||||
});
|
||||
for (const media of filteredCapMedias) {
|
||||
let srclang: string = media.name.slice(-6, -4).toLowerCase();
|
||||
capInfos.push(new CaptionInfo(
|
||||
media._links.content.href,
|
||||
srclang,
|
||||
languageHelper[srclang],
|
||||
));
|
||||
}
|
||||
return capInfos;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method sets the reviced index into currentIndex
|
||||
* This method sets the received index into currentIndex
|
||||
* @param index Selected index
|
||||
*/
|
||||
selectedMedia(index: number) {
|
||||
@@ -71,14 +61,14 @@ export class MediaViewerVideoComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method increade the number of the currentIndex
|
||||
* This method increases the number of the currentIndex
|
||||
*/
|
||||
nextMedia() {
|
||||
this.currentIndex++;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method decrese the number of the currentIndex
|
||||
* This method decreases the number of the currentIndex
|
||||
*/
|
||||
prevMedia() {
|
||||
this.currentIndex--;
|
||||
|
@@ -0,0 +1,38 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { ThemedComponent } from '../../../shared/theme-support/themed.component';
|
||||
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
|
||||
import { MediaViewerVideoComponent } from './media-viewer-video.component';
|
||||
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||
|
||||
/**
|
||||
* Themed wrapper for {@link MediaViewerVideoComponent}.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-media-viewer-video',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedMediaViewerVideoComponent extends ThemedComponent<MediaViewerVideoComponent> {
|
||||
|
||||
@Input() medias: MediaViewerItem[];
|
||||
|
||||
@Input() captions: Bitstream[];
|
||||
|
||||
protected inAndOutputNames: (keyof MediaViewerVideoComponent & keyof this)[] = [
|
||||
'medias',
|
||||
'captions',
|
||||
];
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'MediaViewerVideoComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./media-viewer-video.component');
|
||||
}
|
||||
|
||||
}
|
@@ -5,32 +5,23 @@
|
||||
[showMessage]="false"
|
||||
></ds-themed-loading>
|
||||
<div class="media-viewer" *ngIf="!isLoading">
|
||||
<ng-container *ngIf="mediaList.length > 0">
|
||||
<ng-container *ngIf="videoOptions">
|
||||
<ng-container
|
||||
*ngIf="
|
||||
mediaList[0]?.format === 'video' || mediaList[0]?.format === 'audio'
|
||||
"
|
||||
>
|
||||
<ds-media-viewer-video [medias]="mediaList"></ds-media-viewer-video>
|
||||
<ng-container *ngIf="mediaList.length > 0; else showThumbnail">
|
||||
<ng-container *ngVar="mediaOptions.video && ['audio', 'video'].includes(mediaList[0]?.format) as showVideo">
|
||||
<ng-container *ngVar="mediaOptions.image && mediaList[0]?.format === 'image' as showImage">
|
||||
<ds-themed-media-viewer-video *ngIf="showVideo" [medias]="mediaList" [captions]="captions$ | async"></ds-themed-media-viewer-video>
|
||||
<ds-themed-media-viewer-image *ngIf="showImage" [images]="mediaList"></ds-themed-media-viewer-image>
|
||||
<ng-container *ngIf="showImage || showVideo; else showThumbnail"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="mediaList[0]?.format === 'image'">
|
||||
<ds-media-viewer-image [images]="mediaList"></ds-media-viewer-image>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container
|
||||
*ngIf="
|
||||
((mediaList[0]?.format !== 'image') &&
|
||||
(!videoOptions || mediaList[0]?.format !== 'video') &&
|
||||
(!videoOptions || mediaList[0]?.format !== 'audio')) ||
|
||||
mediaList.length === 0
|
||||
"
|
||||
>
|
||||
<ds-media-viewer-image
|
||||
[image]="mediaList[0]?.thumbnail || thumbnailPlaceholder"
|
||||
[preview]="false"
|
||||
></ds-media-viewer-image>
|
||||
</ng-container>
|
||||
</div>
|
||||
<ng-template #showThumbnail>
|
||||
<ds-themed-media-viewer-image *ngIf="mediaOptions.image && mediaOptions.video"
|
||||
[image]="(thumbnailsRD$ | async)?.payload?.page[0]?._links.content.href || thumbnailPlaceholder"
|
||||
[preview]="false"
|
||||
></ds-themed-media-viewer-image>
|
||||
<ds-thumbnail *ngIf="!(mediaOptions.image && mediaOptions.video)"
|
||||
[thumbnail]="(thumbnailsRD$ | async)?.payload?.page[0]">
|
||||
</ds-thumbnail>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
@@ -61,7 +61,7 @@ describe('MediaViewerComponent', () => {
|
||||
);
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
@@ -94,7 +94,10 @@ describe('MediaViewerComponent', () => {
|
||||
describe('when the bitstreams are loading', () => {
|
||||
beforeEach(() => {
|
||||
comp.mediaList$.next([mockMediaViewerItem]);
|
||||
comp.videoOptions = true;
|
||||
comp.mediaOptions = {
|
||||
image: true,
|
||||
video: true,
|
||||
};
|
||||
comp.isLoading = true;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -118,7 +121,10 @@ describe('MediaViewerComponent', () => {
|
||||
describe('when the bitstreams loading is failed', () => {
|
||||
beforeEach(() => {
|
||||
comp.mediaList$.next([]);
|
||||
comp.videoOptions = true;
|
||||
comp.mediaOptions = {
|
||||
image: true,
|
||||
video: true,
|
||||
};
|
||||
comp.isLoading = false;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -135,7 +141,7 @@ describe('MediaViewerComponent', () => {
|
||||
|
||||
it('should display a default, thumbnail', () => {
|
||||
const defaultThumbnail = fixture.debugElement.query(
|
||||
By.css('ds-media-viewer-image')
|
||||
By.css('ds-themed-media-viewer-image')
|
||||
);
|
||||
expect(defaultThumbnail.nativeElement).toBeDefined();
|
||||
});
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { filter, take } from 'rxjs/operators';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
@@ -11,61 +11,83 @@ import { MediaViewerItem } from '../../core/shared/media-viewer-item.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import { MediaViewerConfig } from '../../../config/media-viewer-config.interface';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
|
||||
/**
|
||||
* This componenet renders the media viewers
|
||||
* This component renders the media viewers
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'ds-media-viewer',
|
||||
templateUrl: './media-viewer.component.html',
|
||||
styleUrls: ['./media-viewer.component.scss'],
|
||||
})
|
||||
export class MediaViewerComponent implements OnInit {
|
||||
export class MediaViewerComponent implements OnDestroy, OnInit {
|
||||
@Input() item: Item;
|
||||
@Input() videoOptions: boolean;
|
||||
|
||||
mediaList$: BehaviorSubject<MediaViewerItem[]>;
|
||||
@Input() mediaOptions: MediaViewerConfig = environment.mediaViewer;
|
||||
|
||||
isLoading: boolean;
|
||||
mediaList$: BehaviorSubject<MediaViewerItem[]> = new BehaviorSubject([]);
|
||||
|
||||
captions$: BehaviorSubject<Bitstream[]> = new BehaviorSubject([]);
|
||||
|
||||
isLoading = true;
|
||||
|
||||
thumbnailPlaceholder = './assets/images/replacement_document.svg';
|
||||
|
||||
constructor(protected bitstreamDataService: BitstreamDataService) {}
|
||||
thumbnailsRD$: Observable<RemoteData<PaginatedList<Bitstream>>>;
|
||||
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach((subscription: Subscription) => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* This metod loads all the Bitstreams and Thumbnails and contert it to media item
|
||||
* This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.mediaList$ = new BehaviorSubject([]);
|
||||
this.isLoading = true;
|
||||
this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD) => {
|
||||
const types: string[] = [
|
||||
...(this.mediaOptions.image ? ['image'] : []),
|
||||
...(this.mediaOptions.video ? ['audio', 'video'] : []),
|
||||
];
|
||||
this.thumbnailsRD$ = this.loadRemoteData('THUMBNAIL');
|
||||
this.subs.push(this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (bitstreamsRD.payload.page.length === 0) {
|
||||
this.isLoading = false;
|
||||
this.mediaList$.next([]);
|
||||
} else {
|
||||
this.loadRemoteData('THUMBNAIL').subscribe((thumbnailsRD) => {
|
||||
this.subs.push(this.thumbnailsRD$.subscribe((thumbnailsRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
for (
|
||||
let index = 0;
|
||||
index < bitstreamsRD.payload.page.length;
|
||||
index++
|
||||
) {
|
||||
bitstreamsRD.payload.page[index].format
|
||||
this.subs.push(bitstreamsRD.payload.page[index].format
|
||||
.pipe(getFirstSucceededRemoteDataPayload())
|
||||
.subscribe((format) => {
|
||||
const current = this.mediaList$.getValue();
|
||||
.subscribe((format: BitstreamFormat) => {
|
||||
const mediaItem = this.createMediaViewerItem(
|
||||
bitstreamsRD.payload.page[index],
|
||||
format,
|
||||
thumbnailsRD.payload && thumbnailsRD.payload.page[index]
|
||||
);
|
||||
this.mediaList$.next([...current, mediaItem]);
|
||||
});
|
||||
if (types.includes(mediaItem.format)) {
|
||||
this.mediaList$.next([...this.mediaList$.getValue(), mediaItem]);
|
||||
} else if (format.mimetype === 'text/vtt') {
|
||||
this.captions$.next([...this.captions$.getValue(), bitstreamsRD.payload.page[index]]);
|
||||
}
|
||||
}));
|
||||
}
|
||||
this.isLoading = false;
|
||||
});
|
||||
}));
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,16 +117,12 @@ export class MediaViewerComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method create MediaViewerItem from incoming bitstreams
|
||||
* @param original original remote data bitstream
|
||||
* This method creates a {@link MediaViewerItem} from incoming {@link Bitstream}s
|
||||
* @param original original bitstream
|
||||
* @param format original bitstream format
|
||||
* @param thumbnail trunbnail remote data bitstream
|
||||
* @param thumbnail thumbnail bitstream
|
||||
*/
|
||||
createMediaViewerItem(
|
||||
original: Bitstream,
|
||||
format: BitstreamFormat,
|
||||
thumbnail: Bitstream
|
||||
): MediaViewerItem {
|
||||
createMediaViewerItem(original: Bitstream, format: BitstreamFormat, thumbnail: Bitstream): MediaViewerItem {
|
||||
const mediaItem = new MediaViewerItem();
|
||||
mediaItem.bitstream = original;
|
||||
mediaItem.format = format.mimetype.split('/')[0];
|
||||
|
@@ -0,0 +1,37 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||
import { MediaViewerComponent } from './media-viewer.component';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { MediaViewerConfig } from '../../../config/media-viewer-config.interface';
|
||||
|
||||
/**
|
||||
* Themed wrapper for {@link MediaViewerComponent}.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-media-viewer',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedMediaViewerComponent extends ThemedComponent<MediaViewerComponent> {
|
||||
|
||||
@Input() item: Item;
|
||||
@Input() mediaOptions: MediaViewerConfig;
|
||||
|
||||
protected inAndOutputNames: (keyof MediaViewerComponent & keyof this)[] = [
|
||||
'item',
|
||||
'mediaOptions',
|
||||
];
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'MediaViewerComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../themes/${themeName}/app/item-page/media-viewer/media-viewer.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./media-viewer.component');
|
||||
}
|
||||
|
||||
}
|
@@ -156,7 +156,8 @@ export class OrcidSyncSettingsComponent implements OnInit {
|
||||
}
|
||||
}),
|
||||
).subscribe((remoteData: RemoteData<ResearcherProfile>) => {
|
||||
if (remoteData.isSuccess) {
|
||||
// hasSucceeded is true if the response is success or successStale
|
||||
if (remoteData.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.success'));
|
||||
this.settingsUpdated.emit();
|
||||
} else {
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { ThemedComponent } from '../../../../../shared/theme-support/themed.component';
|
||||
import { ItemPageTitleFieldComponent } from './item-page-title-field.component';
|
||||
import { Item } from '../../../../../core/shared/item.model';
|
||||
|
||||
/**
|
||||
* Themed wrapper for {@link ItemPageTitleFieldComponent}
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-item-page-title-field',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../../../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedItemPageTitleFieldComponent extends ThemedComponent<ItemPageTitleFieldComponent> {
|
||||
|
||||
protected inAndOutputNames: (keyof ItemPageTitleFieldComponent & keyof this)[] = [
|
||||
'item',
|
||||
];
|
||||
|
||||
@Input() item: Item;
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'ItemPageTitleFieldComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../../themes/${themeName}/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./item-page-title-field.component');
|
||||
}
|
||||
}
|
@@ -9,20 +9,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ng-container *ngIf="!mediaViewer.image">
|
||||
<ng-container *ngIf="!(mediaViewer.image || mediaViewer.video)">
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||
</ng-container>
|
||||
<div *ngIf="mediaViewer.image || mediaViewer.video" class="mb-2">
|
||||
<ds-themed-media-viewer [item]="object"></ds-themed-media-viewer>
|
||||
</div>
|
||||
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||
<ds-themed-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||
|
@@ -10,20 +10,20 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-row">
|
||||
<ds-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-item-page-title-field>
|
||||
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
|
||||
</ds-themed-item-page-title-field>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ng-container *ngIf="!mediaViewer.image">
|
||||
<ng-container *ngIf="!(mediaViewer.image || mediaViewer.video)">
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||
</ng-container>
|
||||
<div *ngIf="mediaViewer.image || mediaViewer.video" class="mb-2">
|
||||
<ds-themed-media-viewer [item]="object"></ds-themed-media-viewer>
|
||||
</div>
|
||||
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||
<ds-themed-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user