Merge remote-tracking branch 'upstream/main' into CST-7216

This commit is contained in:
Enea Jahollari
2023-06-02 15:32:57 +02:00
178 changed files with 36279 additions and 31634 deletions

View File

@@ -15,3 +15,6 @@ trim_trailing_whitespace = false
[*.ts]
quote_type = single
[*.json5]
ij_json_keep_blank_lines_in_code = 3

View File

@@ -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
}
]
}
}
]
}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -266,7 +266,8 @@
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
"src/**/*.html",
"src/**/*.json5"
]
}
}

44
cypress.config.ts Normal file
View 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',
},
});

View File

@@ -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"
}
}

View File

@@ -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');

View File

@@ -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');

View 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');
});
});

View File

@@ -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

View File

@@ -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',);

View 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');
});
});

View 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');
});
});

View File

@@ -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

View File

@@ -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');

View File

@@ -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

View File

@@ -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');

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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');

View File

@@ -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();

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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
@@ -108,3 +127,68 @@ Cypress.Commands.add('login', login);
}
// Add as a Cypress command (i.e. assign to 'cy.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);

View File

@@ -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*$).+/;

View File

@@ -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
```

View File

@@ -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

View 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

View File

@@ -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
View File

@@ -0,0 +1,11 @@
{
"apps": [
{
"name": "dspace-ui",
"cwd": "/app",
"script": "dist/server/main.js",
"instances": "max",
"exec_mode": "cluster"
}
]
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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';
}
}));
}

View File

@@ -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);
}
}
});
}

View File

@@ -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',

View File

@@ -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);
});
});

View File

@@ -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();
}
}
}
}

View File

@@ -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
);

View File

@@ -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>

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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, {

View File

@@ -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}

View File

@@ -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

View File

@@ -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;

View File

@@ -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';

View File

@@ -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();
}

View File

@@ -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({

View File

@@ -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)),

View File

@@ -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');
});
});

View File

@@ -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();

View 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';

View File

@@ -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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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';

View File

@@ -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');
}
}

View File

@@ -1,3 +1,3 @@
<div class="container">
<ds-feedback-form></ds-feedback-form>
<ds-themed-feedback-form></ds-themed-feedback-form>
</div>

View File

@@ -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
];

View File

@@ -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();

View File

@@ -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>) => {

View File

@@ -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)]">

View File

@@ -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,

View File

@@ -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: '';
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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)"
>

View File

@@ -1,4 +1,10 @@
video {
width: 340px;
height: 279px;
width: 100%;
height: auto;
max-width: 340px;
}
.buttons {
display: flex;
gap: .25rem;
}

View File

@@ -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();
});

View File

@@ -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--;

View File

@@ -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');
}
}

View File

@@ -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>

View File

@@ -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();
});

View File

@@ -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];

View File

@@ -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');
}
}

View File

@@ -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 {

View File

@@ -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');
}
}

View File

@@ -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"

View File

@@ -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