Merge branch 'main' into w2p-97184_theme-feedback_contribute-main

This commit is contained in:
lotte
2023-04-13 13:11:58 +02:00
267 changed files with 37498 additions and 31621 deletions

View File

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

View File

@@ -7,7 +7,8 @@
"eslint-plugin-jsdoc", "eslint-plugin-jsdoc",
"eslint-plugin-deprecation", "eslint-plugin-deprecation",
"unused-imports", "unused-imports",
"eslint-plugin-lodash" "eslint-plugin-lodash",
"eslint-plugin-jsonc"
], ],
"overrides": [ "overrides": [
{ {
@@ -224,6 +225,42 @@
"@angular-eslint/template/no-negated-async": "off", "@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "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: env:
# The ci step will test the dspace-angular code against DSpace REST. # 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. # 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_HOST: 127.0.0.1
DSPACE_REST_PORT: 8080 DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server' DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false DSPACE_REST_SSL: false
# Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+ # 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_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 # When Chrome version is specified, we pin to a specific version of Chrome
# Comment this out to use the latest release # Comment this out to use the latest release
#CHROME_VERSION: "90.0.4430.212-1" #CHROME_VERSION: "90.0.4430.212-1"

View File

@@ -88,3 +88,33 @@ jobs:
# Use tags / labels provided by 'docker/metadata-action' above # Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build.outputs.tags }} tags: ${{ steps.meta_build.outputs.tags }}
labels: ${{ steps.meta_build.outputs.labels }} 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

@@ -2,20 +2,27 @@
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
FROM node:18-alpine FROM node:18-alpine
WORKDIR /app
ADD . /app/
EXPOSE 4000
# Ensure Python and other build tools are available # Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64 # These are needed to install some node modules, especially on linux/arm64
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* 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 # 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 # See, for example https://github.com/yarnpkg/yarn/issues/5540
RUN yarn install --network-timeout 300000 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). # On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
# Listen / accept connections from all IP addresses. # Listen / accept connections from all IP addresses.
# NOTE: At this time it is only possible to run Docker container in Production mode # 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 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": { "options": {
"lintFilePatterns": [ "lintFilePatterns": [
"src/**/*.ts", "src/**/*.ts",
"src/**/*.html" "src/**/*.html",
"src/**/*.json5"
] ]
} }
} }

View File

@@ -369,3 +369,8 @@ vocabularies:
- filter: 'subject' - filter: 'subject'
vocabulary: 'srsc' vocabulary: 'srsc'
enabled: true enabled: true
# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query.
comcolSelectionSort:
sortField: 'dc.title'
sortDirection: 'ASC'

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'; import { testA11y } from 'cypress/support/utils';
describe('Breadcrumbs', () => { describe('Breadcrumbs', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
// Visit an Item, as those have more breadcrumbs // 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 // Wait for breadcrumbs to be visible
cy.get('ds-breadcrumbs').should('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'; import { testA11y } from 'cypress/support/utils';
describe('Collection Page', () => { describe('Collection Page', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.visit('/collections/' + TEST_COLLECTION); cy.visit('/collections/'.concat(TEST_COLLECTION));
// <ds-collection-page> tag must be loaded // <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 // Analyze <ds-collection-page> for accessibility issues
testA11y('ds-collection-page'); 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'); cy.visit('/community-list');
// <ds-community-list-page> tag must be loaded // <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 // Open every expand button on page, so that we can scan sub-elements as well
cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click(); cy.get('[data-test="expand-button"]').click({ multiple: true });
// Analyze <ds-community-list-page> for accessibility issues // Analyze <ds-community-list-page> for accessibility issues
// Disable heading-order checks until it is fixed // 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'; import { testA11y } from 'cypress/support/utils';
describe('Community Page', () => { describe('Community Page', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.visit('/communities/' + TEST_COMMUNITY); cy.visit('/communities/'.concat(TEST_COMMUNITY));
// <ds-community-page> tag must be loaded // <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 // Analyze <ds-community-page> for accessibility issues
testA11y('ds-community-page',); 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 { 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'; import { testA11y } from 'cypress/support/utils';
describe('Item Page', () => { describe('Item Page', () => {
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION; const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION);
const ENTITYPAGE = '/entities/publication/' + 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] // 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', () => { it('should redirect to the entity page when navigating to an item page', () => {
@@ -16,7 +16,7 @@ describe('Item Page', () => {
cy.visit(ENTITYPAGE); cy.visit(ENTITYPAGE);
// <ds-item-page> tag must be loaded // <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 // Analyze <ds-item-page> for accessibility issues
// Disable heading-order checks until it is fixed // 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'; import { testA11y } from 'cypress/support/utils';
describe('Item Statistics Page', () => { 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', () => { 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.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
}); });
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
cy.visit(ITEMSTATISTICSPAGE); 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'); cy.get('ds-item-page').should('not.exist');
}); });
it('should contain a "Total visits" section', () => { it('should contain a "Total visits" section', () => {
cy.visit(ITEMSTATISTICSPAGE); 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', () => { it('should contain a "Total visits per month" section', () => {
cy.visit(ITEMSTATISTICSPAGE); 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', () => { it('should pass accessibility tests', () => {
cy.visit(ITEMSTATISTICSPAGE); cy.visit(ITEMSTATISTICSPAGE);
// <ds-item-statistics-page> tag must be loaded // <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 // Analyze <ds-item-statistics-page> for accessibility issues
testA11y('ds-item-statistics-page'); 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 = { const page = {
openLoginMenu() { openLoginMenu() {
@@ -36,7 +36,7 @@ const page = {
describe('Login Modal', () => { describe('Login Modal', () => {
it('should login when clicking button & stay on same page', () => { 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); cy.visit(ENTITYPAGE);
// Login menu should exist // Login menu should exist

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe'; 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'; import { testA11y } from 'cypress/support/utils';
describe('My DSpace page', () => { 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. // 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.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 // At least one recent submission should be displayed
cy.get('[data-test="list-object"]').should('be.visible'); 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. // 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.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 // Click button in sidebar to display detailed view
cy.get('ds-search-sidebar [data-test="detail-view"]').click(); 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 // Analyze <ds-search-page> for accessibility issues
testA11y('ds-my-dspace-page', 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); cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
// Click on the button matching that known 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 // New URL should include /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems'); 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', () => { it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
// request an invalid page (UUIDs at root path aren't valid) // request an invalid page (UUIDs at root path aren't valid)
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); 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', () => { 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 = { const page = {
fillOutQueryInNavBar(query) { fillOutQueryInNavBar(query) {
@@ -27,7 +27,7 @@ describe('Search from Navigation Bar', () => {
page.fillOutQueryInNavBar(query); page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter(); page.submitQueryByPressingEnter();
// New URL should include query param // 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 // Wait for search results to come back from the above GET command
cy.wait('@search-results'); cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
@@ -42,7 +42,7 @@ describe('Search from Navigation Bar', () => {
page.fillOutQueryInNavBar(query); page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter(); page.submitQueryByPressingEnter();
// New URL should include query param // 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 // Wait for search results to come back from the above GET command
cy.wait('@search-results'); cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
@@ -57,7 +57,7 @@ describe('Search from Navigation Bar', () => {
page.fillOutQueryInNavBar(query); page.fillOutQueryInNavBar(query);
page.submitQueryByPressingIcon(); page.submitQueryByPressingIcon();
// New URL should include query param // 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 // Wait for search results to come back from the above GET command
cy.wait('@search-results'); cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe'; 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'; import { testA11y } from 'cypress/support/utils';
describe('Search Page', () => { describe('Search Page', () => {
@@ -13,11 +13,11 @@ describe('Search Page', () => {
}); });
it('should load results and pass accessibility tests', () => { 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); cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
// <ds-search-page> tag must be loaded // <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 // At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible'); 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', () => { 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 // Click button in sidebar to display grid view
cy.get('ds-search-sidebar [data-test="grid-view"]').click(); cy.get('ds-search-sidebar [data-test="grid-view"]').click();
// <ds-search-page> tag must be loaded // <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 // At least one grid object (card) should be displayed
cy.get('[data-test="grid-object"]').should('be.visible'); 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/e2e';
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('New Submission page', () => { describe('New Submission page', () => {
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts // 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', () => { it('should create a new submission when using /submit path & pass accessibility', () => {
// Test that calling /submit with collection & entityType will create a new submission // 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. // 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.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', () => { it('should block submission & show errors if required fields are missing', () => {
// Create a new submission // 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. // 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.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', () => { it('should allow for deposit if all required fields completed & file uploaded', () => {
// Create a new submission // 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. // 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.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
@@ -124,8 +122,6 @@ describe('New Submission page', () => {
// Wait for upload to complete before proceeding // Wait for upload to complete before proceeding
cy.wait('@upload'); 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. // Wait for deposit button to not be disabled & click it.
cy.get('button#deposit').should('not.be.disabled').click(); 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 { 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 // Declare Cypress namespace to help with Intellisense & code completion in IDEs
// ALL custom commands MUST be listed here for code completion to work // ALL custom commands MUST be listed here for code completion to work
// tslint:disable-next-line:no-namespace
declare global { declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress { namespace Cypress {
interface Chainable<Subject = any> { interface Chainable<Subject = any> {
/** /**
@@ -27,6 +32,15 @@ declare global {
* @param password password to login as * @param password password to login as
*/ */
loginViaForm(email: string, password: string): typeof loginViaForm; 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) { if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else { } 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; baseRestUrl = config.rest.baseUrl;
} }
// To login via REST, first we have to do a GET to obtain a valid CSRF token // Now find domain of our REST API, again with a fallback.
cy.request( baseRestUrl + '/api/authn/status' ) let baseDomain = FALLBACK_TEST_REST_DOMAIN;
.then((response) => { if (!config.rest.host) {
// We should receive a CSRF token returned in a response header console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
expect(response.headers).to.have.property('dspace-xsrf-token'); } else {
const csrfToken = response.headers['dspace-xsrf-token']; baseDomain = config.rest.host;
}
// Now, send login POST request including that CSRF token // Create a fake CSRF Token. Set it in the required server-side cookie
cy.request({ const csrfToken = 'fakeLoginCSRFToken';
method: 'POST', cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
url: baseRestUrl + '/api/authn/login',
headers: { 'X-XSRF-TOKEN' : csrfToken},
form: true, // indicates the body should be form urlencoded
body: { user: email, password: password }
}).then((resp) => {
// We expect a successful login
expect(resp.status).to.eq(200);
// We expect to have a valid authorization header returned (with our auth token)
expect(resp.headers).to.have.property('authorization');
// Initialize our AuthTokenInfo object from the authorization header. // Now, send login POST request including that CSRF token
const authheader = resp.headers.authorization as string; cy.request({
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); 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 // Initialize our AuthTokenInfo object from the authorization header.
// This ensures the UI will recognize we are logged in on next "visit()" const authheader = resp.headers.authorization as string;
cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); 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') // Add as a Cypress command (i.e. assign to 'cy.login')
Cypress.Commands.add('login', login); Cypress.Commands.add('login', login);
/** /**
* Login user via displayed login form * Login user via displayed login form
* @param email email to login as * @param email email to login as
* @param password password 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 // Enter email
cy.get('ds-log-in [data-test="email"]').type(email); cy.get('ds-log-in [data-test="email"]').type(email);
// Enter password // Enter password
@@ -107,4 +126,69 @@ Cypress.Commands.add('login', login);
cy.get('ds-log-in [data-test="login-button"]').click(); cy.get('ds-log-in [data-test="login-button"]').click();
} }
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm') // Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
Cypress.Commands.add('loginViaForm', loginViaForm); Cypress.Commands.add('loginViaForm', loginViaForm);
/**
* Generate statistic view event for given object. Useful for testing statistics pages with
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
* generate multiple hits.
*
* NOTE: This requires that "solr-statistics.autoCommit=false" be set on the DSpace backend
* (as it is in our docker-compose-ci.yml used in CI).
* Otherwise, by default, new statistical events won't be saved until Solr's autocommit next triggers.
* @param uuid UUID of object
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/
function generateViewEvent(uuid: string, dsoType: string): void {
// Cypress doesn't have access to the running application in Node.js.
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
// Instead, we'll read our running application's config.json, which contains the configs &
// is regenerated at runtime each time the Angular UI application starts up.
cy.task('readUIConfig').then((str: string) => {
// Parse config into a JSON object
const config = JSON.parse(str);
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found.
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
baseRestUrl = config.rest.baseUrl;
}
// Now find domain of our REST API, again with a fallback.
let baseDomain = FALLBACK_TEST_REST_DOMAIN;
if (!config.rest.host) {
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
} else {
baseDomain = config.rest.host;
}
// Create a fake CSRF Token. Set it in the required server-side cookie
const csrfToken = 'fakeGenerateViewEventCSRFToken';
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
// Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
cy.request({
method: 'POST',
url: baseRestUrl + '/api/statistics/viewevents',
headers: {
[XSRF_REQUEST_HEADER] : csrfToken,
// use a known public IP address to avoid being seen as a "bot"
'X-Forwarded-For': '1.1.1.1',
},
//form: true, // indicates the body should be form urlencoded
body: { targetId: uuid, targetType: dsoType },
}).then((resp) => {
// We expect a 201 (which means statistics event was created)
expect(resp.status).to.eq(201);
});
// Remove cookie with fake CSRF token, as it's no longer needed
cy.clearCookie(DSPACE_XSRF_COOKIE);
});
}
// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
Cypress.Commands.add('generateViewEvent', generateViewEvent);

View File

@@ -30,11 +30,11 @@ beforeEach(() => {
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. // 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. // 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/ // Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/
afterEach(() => { /*afterEach(() => {
cy.window().then((win) => { cy.window().then((win) => {
win.location.href = 'about:blank'; win.location.href = 'about:blank';
}); });
}); });*/
// Global constants used in tests // Global constants used in tests
@@ -43,10 +43,6 @@ afterEach(() => {
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
// (This is the data set used in our CI environment) // (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 // 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_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace'; 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_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 = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace'; 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. 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' 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 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 - docker-compose.yml
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
- docker-compose-rest.yml - 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 ## To start DSpace (REST and Angular) from your branch
This command provides a quick way to start both the frontend & backend from this single codebase
``` ```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d docker-compose -p 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. ## 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._ _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 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 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 ## Ingest test data from AIPDIR
Create an administrator 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 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). ## 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. Travis will perform CI testing of Angular using Node to drive the tests._ _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' db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
# solr.server: Ensure we are using the 'dspacesolr' image for Solr # solr.server: Ensure we are using the 'dspacesolr' image for Solr
solr__P__server: http://dspacesolr:8983/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: depends_on:
- dspacedb - dspacedb
image: dspace/dspace:dspace-7_x-test 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 # 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. # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0' 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: depends_on:
- dspacedb - dspacedb
networks: networks:
@@ -82,8 +82,7 @@ services:
# DSpace Solr container # DSpace Solr container
dspacesolr: dspacesolr:
container_name: dspacesolr container_name: dspacesolr
# Uses official Solr image at https://hub.docker.com/_/solr/ image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}"
image: solr:8.11-slim
# Needs main 'dspace' container to start first to guarantee access to solr_configs # Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on: depends_on:
- dspace - dspace
@@ -96,28 +95,26 @@ services:
tty: true tty: true
working_dir: /var/solr/data working_dir: /var/solr/data
volumes: 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 # Keep Solr data directory between reboots
- solr_data:/var/solr/data - solr_data:/var/solr/data
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr # 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 # * 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 # * Second, copy configsets to this core:
# to the latest configs. If it's a newly created core, this is a no-op. # 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: entrypoint:
- /bin/bash - /bin/bash
- '-c' - '-c'
- | - |
init-var-solr init-var-solr
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority cp -r /opt/solr/server/solr/configsets/dspace/authority/* authority
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai cp -r /opt/solr/server/solr/configsets/dspace/oai/* oai
precreate-core search /opt/solr/server/solr/configsets/dspace/search precreate-core search /opt/solr/server/solr/configsets/dspace/search
cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search cp -r /opt/solr/server/solr/configsets/dspace/search/* search
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics cp -r /opt/solr/server/solr/configsets/dspace/statistics/* statistics
exec solr -f exec solr -f
volumes: volumes:
assetstore: 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", "compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cypress": "9.7.0", "cypress": "12.9.0",
"cypress-axe": "^0.14.0", "cypress-axe": "^1.1.0",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"eslint": "^8.2.0", "eslint": "^8.2.0",
"eslint-plugin-deprecation": "^1.3.2", "eslint-plugin-deprecation": "^1.3.2",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsdoc": "^39.6.4", "eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-jsonc": "^2.6.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.5", "express-static-gzip": "^2.1.5",
@@ -198,7 +199,7 @@
"sass-resources-loader": "^2.1.1", "sass-resources-loader": "^2.1.1",
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"typescript": "~4.5.5", "typescript": "~4.5.5",
"webpack": "^5.69.1", "webpack": "^5.76.0",
"webpack-bundle-analyzer": "^4.4.0", "webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^4.2.0", "webpack-cli": "^4.2.0",
"webpack-dev-server": "^4.5.0" "webpack-dev-server": "^4.5.0"

View File

@@ -8,7 +8,7 @@
<button class="mr-auto btn btn-success addEPerson-button" <button class="mr-auto btn btn-success addEPerson-button"
(click)="isEPersonFormShown = true"> (click)="isEPersonFormShown = true">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">{{labelPrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -30,7 +30,7 @@
<div class="flex-grow-1 mr-3 ml-3"> <div class="flex-grow-1 mr-3 ml-3">
<div class="form-group input-group"> <div class="form-group input-group">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" attr.aria-label="{{labelPrefix + 'search.placeholder' | translate}}" class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
[placeholder]="(labelPrefix + 'search.placeholder' | translate)"> [placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
@@ -72,13 +72,13 @@
<td>{{epersonDto.eperson.email}}</td> <td>{{epersonDto.eperson.email}}</td>
<td> <td>
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)" <button (click)="toggleEditEPerson(epersonDto.eperson)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton" class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}"> title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)" <button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton" class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}"> title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>

View File

@@ -1,7 +1,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
@@ -42,6 +42,7 @@ describe('EPeopleRegistryComponent', () => {
let paginationService; let paginationService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
jasmine.getEnv().allowRespy(true);
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
activeEPerson: null, activeEPerson: null,
@@ -98,7 +99,7 @@ describe('EPeopleRegistryComponent', () => {
deleteEPerson(ePerson: EPerson): Observable<boolean> { deleteEPerson(ePerson: EPerson): Observable<boolean> {
this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => {
return (ePerson2.uuid !== ePerson.uuid); return (ePerson2.uuid !== ePerson.uuid);
}); });
return observableOf(true); return observableOf(true);
}, },
editEPerson(ePerson: EPerson) { editEPerson(ePerson: EPerson) {
@@ -260,17 +261,16 @@ describe('EPeopleRegistryComponent', () => {
describe('delete EPerson button when the isAuthorized returns false', () => { describe('delete EPerson button when the isAuthorized returns false', () => {
let ePeopleDeleteButton; let ePeopleDeleteButton;
beforeEach(() => { beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', { spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false));
isAuthorized: observableOf(false) component.initialisePage();
}); fixture.detectChanges();
}); });
it('should be disabled', () => { it('should be disabled', () => {
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
ePeopleDeleteButton.forEach((deleteButton) => { ePeopleDeleteButton.forEach((deleteButton: DebugElement) => {
expect(deleteButton.nativeElement.disabled).toBe(true); expect(deleteButton.nativeElement.disabled).toBe(true);
}); });
}); });
}); });
}); });

View File

@@ -13,12 +13,13 @@
[formGroup]="formGroup" [formGroup]="formGroup"
[formLayout]="formLayout" [formLayout]="formLayout"
[displayCancel]="false" [displayCancel]="false"
[submitLabel]="submitLabel"
(submitForm)="onSubmit()"> (submitForm)="onSubmit()">
<div before class="btn-group"> <div before class="btn-group">
<button (click)="onCancel()" <button (click)="onCancel()"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button> class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div> </div>
<div between class="btn-group"> <div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()"> <button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}} <i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button> </button>

View File

@@ -165,6 +165,15 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
isImpersonated = false; 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 * Subscription to email field value change
*/ */
@@ -188,6 +197,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.epersonInitial = eperson; this.epersonInitial = eperson;
if (hasValue(eperson)) { if (hasValue(eperson)) {
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
this.displayResetPassword = true;
this.submitLabel = 'form.submit';
} }
})); }));
} }

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
@@ -37,10 +37,10 @@ describe('MembersListComponent', () => {
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup;
let allEPersons; let allEPersons: EPerson[];
let allGroups; let allGroups: Group[];
let epersonMembers; let epersonMembers: EPerson[];
let subgroupMembers; let subgroupMembers: Group[];
let paginationService; let paginationService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -53,7 +53,7 @@ describe('MembersListComponent', () => {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, subgroupMembers: subgroupMembers,
findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
}, },
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> { searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {
@@ -147,6 +147,7 @@ describe('MembersListComponent', () => {
}); });
afterEach(fakeAsync(() => { afterEach(fakeAsync(() => {
fixture.destroy(); fixture.destroy();
fixture.debugElement.nativeElement.remove();
flush(); flush();
component = null; component = null;
fixture.debugElement.nativeElement.remove(); fixture.debugElement.nativeElement.remove();
@@ -168,12 +169,19 @@ describe('MembersListComponent', () => {
describe('search', () => { describe('search', () => {
describe('when searching without query', () => { describe('when searching without query', () => {
let epersonsFound; let epersonsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => {
return observableOf(activeGroup.epersons.includes(ePerson));
});
component.search({ scope: 'metadata', query: '' }); component.search({ scope: 'metadata', query: '' });
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
// Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything
// because they don't change the value of activeGroup.epersons)
jasmine.getEnv().allowRespy(true);
spyOn(component, 'isMemberOfGroup').and.callThrough();
})); }));
it('should display all epersons', () => { it('should display all epersons', () => {
@@ -182,62 +190,56 @@ describe('MembersListComponent', () => {
describe('if eperson is already a eperson', () => { describe('if eperson is already a eperson', () => {
it('should have delete button, else it should have add button', () => { it('should have delete button, else it should have add button', () => {
activeGroup.epersons.map((eperson: EPerson) => { const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id);
epersonsFound.map((foundEPersonRowElement) => { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
if (foundEPersonRowElement.debugElement !== undefined) { const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child'));
const epersonId = foundEPersonRowElement.debugElement.query(By.css('td:first-child')); const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); if (memberIds.includes(epersonId.nativeElement.textContent)) {
if (epersonId.nativeElement.textContent === eperson.id) { expect(addButton).toBeNull();
expect(addButton).toBeUndefined(); expect(deleteButton).not.toBeNull();
expect(deleteButton).toBeDefined(); } else {
} else { expect(deleteButton).toBeNull();
expect(deleteButton).toBeUndefined(); expect(addButton).not.toBeNull();
expect(addButton).toBeDefined(); }
}
}
});
}); });
}); });
}); });
describe('if first add button is pressed', () => { describe('if first add button is pressed', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
addButton.nativeElement.click(); addButton.nativeElement.click();
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
})); }));
it('all groups in search member of selected group', () => { it('then all the ePersons are member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(2); expect(epersonsFound.length).toEqual(2);
epersonsFound.map((foundEPersonRowElement) => { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
if (foundEPersonRowElement.debugElement !== undefined) { const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); expect(addButton).toBeNull();
expect(addButton).toBeUndefined(); expect(deleteButton).not.toBeNull();
expect(deleteButton).toBeDefined();
}
}); });
}); });
}); });
describe('if first delete button is pressed', () => { describe('if first delete button is pressed', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt')); const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
addButton.nativeElement.click(); deleteButton.nativeElement.click();
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
})); }));
it('first eperson in search delete button, because now member', () => { it('then no ePerson is member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
epersonsFound.map((foundEPersonRowElement) => { expect(epersonsFound.length).toEqual(2);
if (foundEPersonRowElement.debugElement !== undefined) { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(deleteButton).toBeUndefined(); expect(deleteButton).toBeNull();
expect(addButton).toBeDefined(); expect(addButton).not.toBeNull();
}
}); });
}); });
}); });

View File

@@ -249,6 +249,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @param ePerson EPerson we want to delete as member from group that is currently being edited * @param ePerson EPerson we want to delete as member from group that is currently being edited
*/ */
deleteMemberFromGroup(ePerson: EpersonDtoModel) { deleteMemberFromGroup(ePerson: EpersonDtoModel) {
ePerson.memberOfGroup = false;
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson);

View File

@@ -65,7 +65,7 @@
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
<p *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</p> <span *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</span>
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)" <button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="addSubgroupToGroup(group)" (click)="addSubgroupToGroup(group)"

View File

@@ -1,14 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
import { import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
ComponentFixture,
fakeAsync,
flush,
inject,
TestBed,
tick,
waitForAsync
} from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -46,8 +38,8 @@ describe('SubgroupsListComponent', () => {
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup;
let subgroups; let subgroups: Group[];
let allGroups; let allGroups: Group[];
let routerStub; let routerStub;
let paginationService; let paginationService;
@@ -65,7 +57,7 @@ describe('SubgroupsListComponent', () => {
getSubgroups(): Group { getSubgroups(): Group {
return this.activeGroup; return this.activeGroup;
}, },
findListByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> { findListByHref(_href: string): Observable<RemoteData<PaginatedList<Group>>> {
return this.subgroups$.pipe( return this.subgroups$.pipe(
map((currentGroups: Group[]) => { map((currentGroups: Group[]) => {
return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups)); return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups));
@@ -133,6 +125,7 @@ describe('SubgroupsListComponent', () => {
}); });
afterEach(fakeAsync(() => { afterEach(fakeAsync(() => {
fixture.destroy(); fixture.destroy();
fixture.debugElement.nativeElement.remove();
flush(); flush();
component = null; component = null;
})); }));
@@ -152,7 +145,7 @@ describe('SubgroupsListComponent', () => {
}); });
describe('if first group delete button is pressed', () => { describe('if first group delete button is pressed', () => {
let groupsFound; let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
addButton.triggerEventHandler('click', { addButton.triggerEventHandler('click', {
@@ -170,7 +163,7 @@ describe('SubgroupsListComponent', () => {
describe('search', () => { describe('search', () => {
describe('when searching with empty query', () => { describe('when searching with empty query', () => {
let groupsFound; let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.search({ query: '' }); component.search({ query: '' });
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
@@ -181,9 +174,9 @@ describe('SubgroupsListComponent', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
expect(groupsFound.length).toEqual(2); expect(groupsFound.length).toEqual(2);
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child')); const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
allGroups.map((group: Group) => { allGroups.map((group: Group) => {
expect(groupIdsFound.find((foundEl) => { expect(groupIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid); return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
@@ -195,30 +188,30 @@ describe('SubgroupsListComponent', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups; const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
if (getSubgroups !== undefined && getSubgroups.length > 0) { if (getSubgroups !== undefined && getSubgroups.length > 0) {
groupsFound.map((foundGroupRowElement) => { groupsFound.map((foundGroupRowElement: DebugElement) => {
if (foundGroupRowElement.debugElement !== undefined) { const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeUndefined(); expect(addButton).toBeNull();
expect(deleteButton).toBeDefined(); if (activeGroup.id === groupId.nativeElement.textContent) {
expect(deleteButton).toBeNull();
} else {
expect(deleteButton).not.toBeNull();
} }
}); });
} else { } else {
getSubgroups.map((group: Group) => { const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id);
groupsFound.map((foundGroupRowElement) => { groupsFound.map((foundGroupRowElement: DebugElement) => {
if (foundGroupRowElement.debugElement !== undefined) { const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const groupId = foundGroupRowElement.debugElement.query(By.css('td:first-child')); const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); if (subgroupIds.includes(groupId.nativeElement.textContent)) {
if (groupId.nativeElement.textContent === group.id) { expect(addButton).toBeNull();
expect(addButton).toBeUndefined(); expect(deleteButton).not.toBeNull();
expect(deleteButton).toBeDefined(); } else {
} else { expect(deleteButton).toBeNull();
expect(deleteButton).toBeUndefined(); expect(addButton).not.toBeNull();
expect(addButton).toBeDefined(); }
}
}
});
}); });
} }
}); });

View File

@@ -7,7 +7,7 @@
<button class="mr-auto btn btn-success" <button class="mr-auto btn btn-success"
[routerLink]="['newGroup']"> [routerLink]="['newGroup']">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">{{messagePrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -17,7 +17,7 @@
<div class="flex-grow-1 mr-3"> <div class="flex-grow-1 mr-3">
<div class="form-group input-group"> <div class="form-group input-group">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" attr.aria-label="{{messagePrefix + 'search.placeholder' | translate}}" class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate"
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" > [placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">

View File

@@ -29,7 +29,7 @@
<tbody> <tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page"> <tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td> <td>
<label> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async" [checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)" (change)="selectBitStreamFormat(bitstreamFormat, $event)"

View File

@@ -29,7 +29,7 @@
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page" <tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page"
[ngClass]="{'table-primary' : isActive(schema) | async}"> [ngClass]="{'table-primary' : isActive(schema) | async}">
<td> <td>
<label> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(schema) | async" [checked]="isSelected(schema) | async"
(change)="selectMetadataSchema(schema, $event)" (change)="selectMetadataSchema(schema, $event)"

View File

@@ -1,3 +1,7 @@
.selectable-row:hover { .selectable-row:hover {
cursor: pointer; cursor: pointer;
} }
:host ::ng-deep #metadatadataschemagroup {
display: flex;
}

View File

@@ -34,14 +34,14 @@
<tr *ngFor="let field of fields?.page" <tr *ngFor="let field of fields?.page"
[ngClass]="{'table-primary' : isActive(field) | async}"> [ngClass]="{'table-primary' : isActive(field) | async}">
<td> <td>
<label> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(field) | async" [checked]="isSelected(field) | async"
(change)="selectMetadataField(field, $event)"> (change)="selectMetadataField(field, $event)">
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td> <td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td> <td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier" class="mb-0">.</label>{{field.qualifier}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td> <td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -1,3 +1,8 @@
.selectable-row:hover { .selectable-row:hover {
cursor: pointer; cursor: pointer;
} }
:host ::ng-deep #metadatadatafieldgroup {
display: flex;
flex-wrap: wrap;
}

View File

@@ -218,7 +218,8 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
{ {
path: 'statistics', path: 'statistics',
loadChildren: () => import('./statistics-page/statistics-page-routing.module') loadChildren: () => import('./statistics-page/statistics-page-routing.module')
.then((m) => m.StatisticsPageRoutingModule) .then((m) => m.StatisticsPageRoutingModule),
canActivate: [EndUserAgreementCurrentUserGuard],
}, },
{ {
path: HEALTH_PAGE_PATH, path: HEALTH_PAGE_PATH,
@@ -228,7 +229,7 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
{ {
path: ACCESS_CONTROL_MODULE_PATH, path: ACCESS_CONTROL_MODULE_PATH,
loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule), loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule),
canActivate: [GroupAdministratorGuard], canActivate: [GroupAdministratorGuard, EndUserAgreementCurrentUserGuard],
}, },
{ {
path: 'subscriptions', path: 'subscriptions',

View File

@@ -618,7 +618,11 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
// TODO: Set bitstream to primary when supported // TODO: Set bitstream to primary when supported
const primary = rawForm.fileNamePrimaryContainer.primaryBitstream; const primary = rawForm.fileNamePrimaryContainer.primaryBitstream;
Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName); Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName);
Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description); if (isEmpty(rawForm.descriptionContainer.description)) {
delete newMetadata['dc.description'];
} else {
Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description);
}
if (this.isIIIF) { if (this.isIIIF) {
// It's helpful to remove these metadata elements entirely when the form value is empty. // It's helpful to remove these metadata elements entirely when the form value is empty.
// This avoids potential issues on the REST side and makes it possible to do things like // This avoids potential issues on the REST side and makes it possible to do things like

View File

@@ -22,6 +22,7 @@ import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { APP_CONFIG } from '../../../config/app-config.interface'; import { APP_CONFIG } from '../../../config/app-config.interface';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { SortDirection } from '../../core/cache/models/sort-options.model';
describe('BrowseByDatePageComponent', () => { describe('BrowseByDatePageComponent', () => {
let comp: 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 = { const mockBrowseService = {
getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]),
getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]),
getFirstItemFor: () => createSuccessfulRemoteDataObject$(firstItem) getFirstItemFor: (definition: string, scope?: string, sortDirection?: SortDirection) => null
}; };
const mockDsoService = { const mockDsoService = {
findById: () => createSuccessfulRemoteDataObject$(mockCommunity) findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
@@ -91,9 +102,14 @@ describe('BrowseByDatePageComponent', () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(BrowseByDatePageComponent); 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; comp = fixture.componentInstance;
fixture.detectChanges();
route = (comp as any).route; route = (comp as any).route;
fixture.detectChanges();
}); });
it('should initialize the list of items', () => { 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', () => { 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 { ChangeDetectorRef, Component, Inject } from '@angular/core';
import { import {
BrowseByMetadataPageComponent, BrowseByMetadataPageComponent,
browseParamsToOptions, getBrowseSearchOptions browseParamsToOptions,
getBrowseSearchOptions
} from '../browse-by-metadata-page/browse-by-metadata-page.component'; } from '../browse-by-metadata-page/browse-by-metadata-page.component';
import { combineLatest as observableCombineLatest } from 'rxjs'; 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 { hasValue, isNotEmpty } from '../../shared/empty.util';
import { ActivatedRoute, Params, Router } from '@angular/router'; import { ActivatedRoute, Params, Router } from '@angular/router';
import { BrowseService } from '../../core/browse/browse.service'; 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { isValidDate } from '../../shared/date.util'; 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({ @Component({
selector: 'ds-browse-by-date-page', selector: 'ds-browse-by-date-page',
@@ -72,30 +73,24 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
/** /**
* Update the StartsWith options * Update the StartsWith options
* In this implementation, it creates a list of years starting from now, going all the way back to the earliest * In this implementation, it creates a list of years starting from the most recent item or the current year, going
* date found on an item within this scope. The further back in time, the bigger the change in years become to avoid * all the way back to the earliest date found on an item within this scope. The further back in time, the bigger
* extremely long lists with a one-year difference. * 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. * 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 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 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 * @param scope The scope under which to fetch the earliest item for
*/ */
updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) { 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.subs.push(
this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData<Item>) => { observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => {
let lowerLimit = this.appConfig.browseBy.defaultLowerLimit; let lowerLimit = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit);
if (hasValue(firstItemRD.payload)) { let upperLimit = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear());
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();
}
}
const options = []; const options = [];
const currentYear = new Date().getUTCFullYear(); const oneYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5;
const oneYearBreak = Math.floor((currentYear - this.appConfig.browseBy.oneYearLimit) / 5) * 5; const fiveYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10;
const fiveYearBreak = Math.floor((currentYear - this.appConfig.browseBy.fiveYearLimit) / 10) * 10;
if (lowerLimit <= fiveYearBreak) { if (lowerLimit <= fiveYearBreak) {
lowerLimit -= 10; lowerLimit -= 10;
} else if (lowerLimit <= oneYearBreak) { } else if (lowerLimit <= oneYearBreak) {
@@ -103,7 +98,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
} else { } else {
lowerLimit -= 1; lowerLimit -= 1;
} }
let i = currentYear; let i = upperLimit;
while (i > lowerLimit) { while (i > lowerLimit) {
options.push(i); options.push(i);
if (i <= fiveYearBreak) { 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]) => { ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => {
this.browseId = params.id || this.defaultBrowseId; this.browseId = params.id || this.defaultBrowseId;
this.authority = params.authority; 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)) { if (isNotEmpty(this.value)) {
this.updatePageWithItems( this.updatePageWithItems(
browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority);
@@ -305,7 +314,7 @@ export function browseParamsToOptions(params: any,
metadata, metadata,
paginationConfig, paginationConfig,
sortConfig, sortConfig,
+params.startsWith || params.startsWith, params.startsWith,
params.scope, params.scope,
fetchThumbnail fetchThumbnail
); );

View File

@@ -28,14 +28,14 @@
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row mt-2"> <div class="row mt-2">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<ds-search-form id="search-form" <ds-themed-search-form id="search-form"
[query]="(searchOptions$ | async)?.query" [query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope" [scope]="(searchOptions$ | async)?.scope"
[currentUrl]="'./'" [currentUrl]="'./'"
[inPlaceSearch]="true" [inPlaceSearch]="true"
[searchPlaceholder]="'collection.edit.item-mapper.search-form.placeholder' | translate" [searchPlaceholder]="'collection.edit.item-mapper.search-form.placeholder' | translate"
(submitSearch)="performedSearch = true"> (submitSearch)="performedSearch = true">
</ds-search-form> </ds-themed-search-form>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit, Inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs'; import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs';
import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators'; import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators';
@@ -29,6 +29,7 @@ import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { getCollectionPageRoute } from './collection-page-routing-paths'; import { getCollectionPageRoute } from './collection-page-routing-paths';
import { redirectOn4xx } from '../core/shared/authorized.operators'; import { redirectOn4xx } from '../core/shared/authorized.operators';
import { BROWSE_LINKS_TO_FOLLOW } from '../core/browse/browse.service'; import { BROWSE_LINKS_TO_FOLLOW } from '../core/browse/browse.service';
import { APP_CONFIG, AppConfig } from '../../../src/config/app-config.interface';
@Component({ @Component({
selector: 'ds-collection-page', selector: 'ds-collection-page',
@@ -69,13 +70,15 @@ export class CollectionPageComponent implements OnInit {
private authService: AuthService, private authService: AuthService,
private paginationService: PaginationService, private paginationService: PaginationService,
private authorizationDataService: AuthorizationDataService, private authorizationDataService: AuthorizationDataService,
@Inject(APP_CONFIG) public appConfig: AppConfig,
) { ) {
this.paginationConfig = new PaginationComponentOptions(); this.paginationConfig = Object.assign(new PaginationComponentOptions(), {
this.paginationConfig.id = 'cp'; id: 'cp',
this.paginationConfig.pageSize = 5; currentPage: 1,
this.paginationConfig.currentPage = 1; pageSize: this.appConfig.browseBy.pageSize,
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC); });
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC);
} }
ngOnInit(): void { ngOnInit(): void {

View File

@@ -28,7 +28,8 @@
[title]="'toggle ' + node.name" [title]="'toggle ' + node.name"
[attr.aria-label]="'toggle ' + node.name" [attr.aria-label]="'toggle ' + node.name"
(click)="toggleExpanded(node)" (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'}}" <span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}"
aria-hidden="true"></span> aria-hidden="true"></span>
</button> </button>

View File

@@ -8,7 +8,7 @@ import { PostRequest } from '../data/request.models';
import { import {
XSRF_REQUEST_HEADER, XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER XSRF_RESPONSE_HEADER
} from '../xsrf/xsrf.interceptor'; } from '../xsrf/xsrf.constants';
describe(`ServerAuthRequestService`, () => { describe(`ServerAuthRequestService`, () => {
let href: string; let href: string;

View File

@@ -13,7 +13,7 @@ import {
XSRF_REQUEST_HEADER, XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER, XSRF_RESPONSE_HEADER,
DSPACE_XSRF_COOKIE DSPACE_XSRF_COOKIE
} from '../xsrf/xsrf.interceptor'; } from '../xsrf/xsrf.constants';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Observable } from 'rxjs'; 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 { HrefOnlyDataService } from '../data/href-only-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service'; import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { SortDirection } from '../cache/models/sort-options.model';
export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [ 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 * Get the first item for a metadata definition in an optional scope
* @param definition * @param definition
* @param scope * @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( const href$ = this.getBrowseDefinitions().pipe(
getBrowseDefinitionLinks(definition), getBrowseDefinitionLinks(definition),
hasValueOperator(), hasValueOperator(),
@@ -177,6 +179,9 @@ export class BrowseService {
} }
args.push('page=0'); args.push('page=0');
args.push('size=1'); args.push('size=1');
if (sortDirection) {
args.push('sort=default,' + sortDirection);
}
if (isNotEmpty(args)) { if (isNotEmpty(args)) {
href = new URLCombiner(href, `?${args.join('&')}`).toString(); href = new URLCombiner(href, `?${args.join('&')}`).toString();
} }

View File

@@ -52,7 +52,7 @@ describe(`LocaleInterceptor`, () => {
expect(httpRequest.request.headers.has('Accept-Language')); expect(httpRequest.request.headers.has('Accept-Language'));
const lang = httpRequest.request.headers.get('Accept-Language'); const lang = httpRequest.request.headers.get('Accept-Language');
expect(lang).toBeDefined(); expect(lang).not.toBeNull();
expect(lang).toBe(languageList.toString()); expect(lang).toBe(languageList.toString());
}); });

View File

@@ -62,25 +62,33 @@ describe('LocaleService test suite', () => {
}); });
describe('getCurrentLanguageCode', () => { 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'); spyOnGet.and.returnValue('de');
expect(service.getCurrentLanguageCode()).toBe('de'); expect(service.getCurrentLanguageCode()).toBe('de');
}); });
describe('', () => { it('should return the default language if the cookie language is disabled', () => {
beforeEach(() => { spyOnGet.and.returnValue('disabled');
spyOn(translateService, 'getLangs').and.returnValue(langList); expect(service.getCurrentLanguageCode()).toBe('en');
}); });
it('should return language from browser setting', () => { it('should return the default language if the cookie language does not exist', () => {
spyOn(translateService, 'getBrowserLang').and.returnValue('it'); spyOnGet.and.returnValue('does-not-exist');
expect(service.getCurrentLanguageCode()).toBe('it'); expect(service.getCurrentLanguageCode()).toBe('en');
}); });
it('should return default language from config', () => { it('should return language from browser setting', () => {
spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); spyOn(translateService, 'getBrowserLang').and.returnValue('it');
expect(service.getCurrentLanguageCode()).toBe('en'); 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 { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { RouteService } from '../services/route.service'; import { RouteService } from '../services/route.service';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { LangConfig } from '../../../config/lang-config.interface';
export const LANG_COOKIE = 'dsLanguage'; export const LANG_COOKIE = 'dsLanguage';
@@ -52,8 +53,7 @@ export class LocaleService {
getCurrentLanguageCode(): string { getCurrentLanguageCode(): string {
// Attempt to get the language from a cookie // Attempt to get the language from a cookie
let lang = this.getLanguageCodeFromCookie(); let lang = this.getLanguageCodeFromCookie();
if (isEmpty(lang)) { if (isEmpty(lang) || environment.languages.find((langConfig: LangConfig) => langConfig.code === lang && langConfig.active) === undefined) {
// Cookie not found
// Attempt to get the browser language from the user // Attempt to get the browser language from the user
if (this.translate.getLangs().includes(this.translate.getBrowserLang())) { if (this.translate.getLangs().includes(this.translate.getBrowserLang())) {
lang = 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

@@ -67,7 +67,7 @@ describe(`XsrfInterceptor`, () => {
expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeTrue(); expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeTrue();
expect(httpRequest.request.withCredentials).toBeTrue(); expect(httpRequest.request.withCredentials).toBeTrue();
const token = httpRequest.request.headers.get('X-XSRF-TOKEN'); const token = httpRequest.request.headers.get('X-XSRF-TOKEN');
expect(token).toBeDefined(); expect(token).not.toBeNull();
expect(token).toBe(testToken.toString()); expect(token).toBe(testToken.toString());
httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
@@ -116,11 +116,11 @@ describe(`XsrfInterceptor`, () => {
// ensure mock XSRF token is in response // ensure mock XSRF token is in response
expect(response.headers.has('DSPACE-XSRF-TOKEN')).toBeTrue(); expect(response.headers.has('DSPACE-XSRF-TOKEN')).toBeTrue();
const token = response.headers.get('DSPACE-XSRF-TOKEN'); const token = response.headers.get('DSPACE-XSRF-TOKEN');
expect(token).toBeDefined(); expect(token).not.toBeNull();
expect(token).toBe(mockNewXSRFToken.toString()); expect(token).toBe(mockNewXSRFToken.toString());
// ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header // ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header
expect(cookieService.get('XSRF-TOKEN')).toBeDefined(); expect(cookieService.get('XSRF-TOKEN')).not.toBeNull();
expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString()); expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString());
done(); done();
@@ -153,7 +153,7 @@ describe(`XsrfInterceptor`, () => {
expect(error.statusText).toBe(mockErrorText); expect(error.statusText).toBe(mockErrorText);
// ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header // ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header
expect(cookieService.get('XSRF-TOKEN')).toBeDefined(); expect(cookieService.get('XSRF-TOKEN')).not.toBeNull();
expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString()); expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString());
done(); done();

View File

@@ -12,15 +12,7 @@ import { Observable, throwError } from 'rxjs';
import { tap, catchError } from 'rxjs/operators'; import { tap, catchError } from 'rxjs/operators';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from './xsrf.constants';
// 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';
/** /**
* Custom Http Interceptor intercepting Http Requests & Responses to * 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> <ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-themed-item-page-title-field>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="row"> <div class="row">

View File

@@ -1,7 +1,7 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button> <ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-themed-item-page-title-field>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="row"> <div class="row">

View File

@@ -1,7 +1,7 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button> <ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-themed-item-page-title-field>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="row"> <div class="row">

View File

@@ -1,7 +1,7 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button> <ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-themed-item-page-title-field>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="row"> <div class="row">

View File

@@ -1,7 +1,7 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button> <ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="object"> <ds-themed-item-page-title-field class="mr-auto" [item]="object">
</ds-item-page-title-field> </ds-themed-item-page-title-field>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="row"> <div class="row">

View File

@@ -1,7 +1,7 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button> <ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-themed-item-page-title-field>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="row"> <div class="row">
@@ -18,12 +18,12 @@
<!--[fields]="['project.identifier.status']"--> <!--[fields]="['project.identifier.status']"-->
<!--[label]="'project.page.status'">--> <!--[label]="'project.page.status'">-->
<!--</ds-generic-item-page-field>--> <!--</ds-generic-item-page-field>-->
<ds-metadata-representation-list <ds-themed-metadata-representation-list
[parentItem]="object" [parentItem]="object"
[itemType]="'OrgUnit'" [itemType]="'OrgUnit'"
[metadataFields]="['project.contributor.other']" [metadataFields]="['project.contributor.other']"
[label]="'project.page.contributor' | translate"> [label]="'project.page.contributor' | translate">
</ds-metadata-representation-list> </ds-themed-metadata-representation-list>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['project.identifier.funder']" [fields]="['project.identifier.funder']"
[label]="'project.page.funder'"> [label]="'project.page.funder'">

View File

@@ -3,7 +3,7 @@
<ng-container *ngIf="(site$ | async) as site"> <ng-container *ngIf="(site$ | async) as site">
<ds-view-tracker [object]="site"></ds-view-tracker> <ds-view-tracker [object]="site"></ds-view-tracker>
</ng-container> </ng-container>
<ds-search-form [inPlaceSearch]="false" [searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-search-form> <ds-themed-search-form [inPlaceSearch]="false" [searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-themed-search-form>
<ds-themed-top-level-community-list></ds-themed-top-level-community-list> <ds-themed-top-level-community-list></ds-themed-top-level-community-list>
<ds-recent-item-list *ngIf="recentSubmissionspageSize>0"></ds-recent-item-list> <ds-recent-item-list *ngIf="recentSubmissionspageSize>0"></ds-recent-item-list>
</div> </div>

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"> <div class="container">
<ds-feedback-form></ds-feedback-form> <ds-themed-feedback-form></ds-themed-feedback-form>
</div> </div>

View File

@@ -10,6 +10,7 @@ import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end
import { ThemedPrivacyComponent } from './privacy/themed-privacy.component'; import { ThemedPrivacyComponent } from './privacy/themed-privacy.component';
import { FeedbackComponent } from './feedback/feedback.component'; import { FeedbackComponent } from './feedback/feedback.component';
import { FeedbackFormComponent } from './feedback/feedback-form/feedback-form.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 { ThemedFeedbackComponent } from './feedback/themed-feedback.component';
import { FeedbackGuard } from '../core/feedback/feedback.guard'; import { FeedbackGuard } from '../core/feedback/feedback.guard';
@@ -23,6 +24,7 @@ const DECLARATIONS = [
ThemedPrivacyComponent, ThemedPrivacyComponent,
FeedbackComponent, FeedbackComponent,
FeedbackFormComponent, FeedbackFormComponent,
ThemedFeedbackFormComponent,
ThemedFeedbackComponent ThemedFeedbackComponent
]; ];

View File

@@ -0,0 +1,30 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../core/shared/item.model';
import { ItemAlertsComponent } from './item-alerts.component';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
/**
* Themed wrapper for {@link ItemAlertsComponent}
*/
@Component({
selector: 'ds-themed-item-alerts',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
})
export class ThemedItemAlertsComponent extends ThemedComponent<ItemAlertsComponent> {
protected inAndOutputNames: (keyof ItemAlertsComponent & keyof this)[] = ['item'];
@Input() item: Item;
protected getComponentName(): string {
return 'ItemAlertsComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/item-page/alerts/item-alerts.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./item-alerts.component');
}
}

View File

@@ -27,13 +27,13 @@
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row mt-2"> <div class="row mt-2">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<ds-search-form id="search-form" <ds-themed-search-form id="search-form"
[query]="(searchOptions$ | async)?.query" [query]="(searchOptions$ | async)?.query"
[currentUrl]="'./'" [currentUrl]="'./'"
[inPlaceSearch]="true" [inPlaceSearch]="true"
[searchPlaceholder]="'item.edit.item-mapper.search-form.placeholder' | translate" [searchPlaceholder]="'item.edit.item-mapper.search-form.placeholder' | translate"
(submitSearch)="performedSearch = true"> (submitSearch)="performedSearch = true">
</ds-search-form> </ds-themed-search-form>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,32 @@
import { Component, Input } from '@angular/core';
import { ThemedComponent } from '../../../../shared/theme-support/themed.component';
import { FullFileSectionComponent } from './full-file-section.component';
import { Item } from '../../../../core/shared/item.model';
/**
* Themed wrapper for {@link FullFileSectionComponent}
*/
@Component({
selector: 'ds-themed-item-page-full-file-section',
styleUrls: [],
templateUrl: './../../../../shared/theme-support/themed.component.html',
})
export class ThemedFullFileSectionComponent extends ThemedComponent<FullFileSectionComponent> {
@Input() item: Item;
protected inAndOutputNames: (keyof FullFileSectionComponent & keyof this)[] = ['item'];
protected getComponentName(): string {
return 'FullFileSectionComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/item-page/full/field-components/file-section/full-file-section.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./full-file-section.component');
}
}

View File

@@ -1,14 +1,13 @@
<div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="container" *ngVar="(itemRD$ | async) as itemRD">
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
<div *ngIf="itemRD?.payload as item"> <div *ngIf="itemRD?.payload as item">
<ds-item-alerts [item]="item"></ds-item-alerts> <ds-themed-item-alerts [item]="item"></ds-themed-item-alerts>
<ds-item-versions-notice [item]="item"></ds-item-versions-notice> <ds-item-versions-notice [item]="item"></ds-item-versions-notice>
<ds-view-tracker [object]="item"></ds-view-tracker> <ds-view-tracker [object]="item"></ds-view-tracker>
<div *ngIf="!item.isWithdrawn || (isAdmin$|async)" class="full-item-info"> <div *ngIf="!item.isWithdrawn || (isAdmin$|async)" class="full-item-info">
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="item"></ds-item-page-title-field> <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> <ds-dso-edit-menu></ds-dso-edit-menu>
</div> </div>
<div class="simple-view-link my-3" *ngIf="!fromSubmissionObject"> <div class="simple-view-link my-3" *ngIf="!fromSubmissionObject">
<a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]"> <a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]">
@@ -28,7 +27,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section> <ds-themed-item-page-full-file-section [item]="item"></ds-themed-item-page-full-file-section>
<ds-item-page-collections [item]="item"></ds-item-page-collections> <ds-item-page-collections [item]="item"></ds-item-page-collections>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions> <ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
<div class="button-row bottom" *ngIf="fromSubmissionObject"> <div class="button-row bottom" *ngIf="fromSubmissionObject">

View File

@@ -104,9 +104,13 @@ describe('FullItemPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
})); }));
afterEach(() => {
fixture.debugElement.nativeElement.remove();
});
it('should display the item\'s metadata', () => { it('should display the item\'s metadata', () => {
const table = fixture.debugElement.query(By.css('table')); const table = fixture.debugElement.query(By.css('table'));
for (const metadatum of mockItem.allMetadata([])) { for (const metadatum of mockItem.allMetadata(Object.keys(mockItem.metadata))) {
expect(table.nativeElement.innerHTML).toContain(metadatum.value); expect(table.nativeElement.innerHTML).toContain(metadatum.value);
} }
}); });
@@ -137,7 +141,7 @@ describe('FullItemPageComponent', () => {
it('should display the item', () => { it('should display the item', () => {
const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); const objectLoader = fixture.debugElement.query(By.css('.full-item-info'));
expect(objectLoader.nativeElement).toBeDefined(); expect(objectLoader.nativeElement).not.toBeNull();
}); });
}); });
describe('when the item is withdrawn and the user is not an admin', () => { describe('when the item is withdrawn and the user is not an admin', () => {
@@ -161,7 +165,7 @@ describe('FullItemPageComponent', () => {
it('should display the item', () => { it('should display the item', () => {
const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); const objectLoader = fixture.debugElement.query(By.css('.full-item-info'));
expect(objectLoader.nativeElement).toBeDefined(); expect(objectLoader).not.toBeNull();
}); });
}); });
@@ -173,7 +177,7 @@ describe('FullItemPageComponent', () => {
it('should display the item', () => { it('should display the item', () => {
const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); const objectLoader = fixture.debugElement.query(By.css('.full-item-info'));
expect(objectLoader.nativeElement).toBeDefined(); expect(objectLoader).not.toBeNull();
}); });
}); });
}); });

View File

@@ -34,8 +34,11 @@ import { ResearchEntitiesModule } from '../entity-groups/research-entities/resea
import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
import { MediaViewerComponent } from './media-viewer/media-viewer.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 { 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 { 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 { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component'; import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
import { VersionPageComponent } from './version-page/version-page/version-page.component'; import { VersionPageComponent } from './version-page/version-page/version-page.component';
@@ -53,7 +56,10 @@ import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/
import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { FileSectionComponent } from './simple/field-components/file-section/file-section.component';
import { ItemSharedModule } from './item-shared.module'; import { ItemSharedModule } from './item-shared.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module';
import { ThemedItemAlertsComponent } from './alerts/themed-item-alerts.component';
import {
ThemedFullFileSectionComponent
} from './full/field-components/file-section/themed-full-file-section.component';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -76,14 +82,18 @@ const DECLARATIONS = [
ItemPageFieldComponent, ItemPageFieldComponent,
CollectionsComponent, CollectionsComponent,
FullFileSectionComponent, FullFileSectionComponent,
ThemedFullFileSectionComponent,
PublicationComponent, PublicationComponent,
UntypedItemComponent, UntypedItemComponent,
ItemComponent, ItemComponent,
UploadBitstreamComponent, UploadBitstreamComponent,
AbstractIncrementalListComponent, AbstractIncrementalListComponent,
MediaViewerComponent, MediaViewerComponent,
ThemedMediaViewerComponent,
MediaViewerVideoComponent, MediaViewerVideoComponent,
ThemedMediaViewerVideoComponent,
MediaViewerImageComponent, MediaViewerImageComponent,
ThemedMediaViewerImageComponent,
MiradorViewerComponent, MiradorViewerComponent,
VersionPageComponent, VersionPageComponent,
OrcidPageComponent, OrcidPageComponent,
@@ -91,6 +101,7 @@ const DECLARATIONS = [
OrcidSyncSettingsComponent, OrcidSyncSettingsComponent,
OrcidQueueComponent, OrcidQueueComponent,
ItemAlertsComponent, ItemAlertsComponent,
ThemedItemAlertsComponent,
BitstreamRequestACopyPageComponent, BitstreamRequestACopyPageComponent,
]; ];

View File

@@ -13,6 +13,9 @@ import { MetadataValuesComponent } from './field-components/metadata-values/meta
import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component'; import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component';
import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component';
import { RelatedItemsComponent } from './simple/related-items/related-items-component'; import { RelatedItemsComponent } from './simple/related-items/related-items-component';
import {
ThemedMetadataRepresentationListComponent
} from './simple/metadata-representation-list/themed-metadata-representation-list.component';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
ItemVersionsDeleteModalComponent, ItemVersionsDeleteModalComponent,
@@ -27,6 +30,7 @@ const COMPONENTS = [
MetadataValuesComponent, MetadataValuesComponent,
GenericItemPageFieldComponent, GenericItemPageFieldComponent,
MetadataRepresentationListComponent, MetadataRepresentationListComponent,
ThemedMetadataRepresentationListComponent,
RelatedItemsComponent, RelatedItemsComponent,
]; ];

View File

@@ -1,6 +1,20 @@
.ngx-gallery { :host ::ng-deep {
display: inline-block; .ngx-gallery {
margin-bottom: 20px; width: unset !important;
width: 340px !important; height: unset !important;
height: 279px !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

@@ -18,7 +18,7 @@ export class MediaViewerImageComponent implements OnInit {
@Input() preview?: boolean; @Input() preview?: boolean;
@Input() image?: string; @Input() image?: string;
loggedin: boolean; thumbnailPlaceholder = './assets/images/replacement_image.svg';
galleryOptions: NgxGalleryOptions[]; galleryOptions: NgxGalleryOptions[];
galleryImages: NgxGalleryImage[]; galleryImages: NgxGalleryImage[];
@@ -28,7 +28,10 @@ export class MediaViewerImageComponent implements OnInit {
*/ */
isAuthenticated$: Observable<boolean>; isAuthenticated$: Observable<boolean>;
constructor(private authService: AuthService) {} constructor(
protected authService: AuthService,
) {
}
/** /**
* Thi method sets up the gallery settings and data * Thi method sets up the gallery settings and data
@@ -69,20 +72,20 @@ export class MediaViewerImageComponent implements OnInit {
* @param medias input NgxGalleryImage array * @param medias input NgxGalleryImage array
*/ */
convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] { convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] {
const mappadImages = []; const mappedImages = [];
for (const image of medias) { for (const image of medias) {
if (image.format === 'image') { if (image.format === 'image') {
mappadImages.push({ mappedImages.push({
small: image.thumbnail small: image.thumbnail
? image.thumbnail ? image.thumbnail
: './assets/images/replacement_image.svg', : this.thumbnailPlaceholder,
medium: image.thumbnail medium: image.thumbnail
? image.thumbnail ? image.thumbnail
: './assets/images/replacement_image.svg', : this.thumbnailPlaceholder,
big: image.bitstream._links.content.href, 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,4 +1,10 @@
video { video {
width: 340px; width: 100%;
height: 279px; height: auto;
max-width: 340px;
}
.buttons {
display: flex;
gap: .25rem;
} }

View File

@@ -4,7 +4,7 @@ import { languageHelper } from './language-helper';
import { CaptionInfo} from './caption-info'; import { CaptionInfo} from './caption-info';
/** /**
* 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({ @Component({
selector: 'ds-media-viewer-video', selector: 'ds-media-viewer-video',
@@ -24,8 +24,6 @@ export class MediaViewerVideoComponent implements OnInit {
audio: './assets/images/replacement_audio.svg', audio: './assets/images/replacement_audio.svg',
}; };
replacementThumbnail: string;
ngOnInit() { ngOnInit() {
this.isCollapsed = false; this.isCollapsed = false;
this.filteredMedias = this.medias.filter((media) => media.format === 'audio' || media.format === 'video'); this.filteredMedias = this.medias.filter((media) => media.format === 'audio' || media.format === 'video');

View File

@@ -0,0 +1,34 @@
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';
/**
* 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[];
protected inAndOutputNames: (keyof MediaViewerVideoComponent & keyof this)[] = [
'medias',
];
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

@@ -12,11 +12,11 @@
mediaList[0]?.format === 'video' || mediaList[0]?.format === 'audio' mediaList[0]?.format === 'video' || mediaList[0]?.format === 'audio'
" "
> >
<ds-media-viewer-video [medias]="mediaList"></ds-media-viewer-video> <ds-themed-media-viewer-video [medias]="mediaList"></ds-themed-media-viewer-video>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-container *ngIf="mediaList[0]?.format === 'image'"> <ng-container *ngIf="mediaList[0]?.format === 'image'">
<ds-media-viewer-image [images]="mediaList"></ds-media-viewer-image> <ds-themed-media-viewer-image [images]="mediaList"></ds-themed-media-viewer-image>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-container <ng-container
@@ -27,10 +27,10 @@
mediaList.length === 0 mediaList.length === 0
" "
> >
<ds-media-viewer-image <ds-themed-media-viewer-image
[image]="mediaList[0]?.thumbnail || thumbnailPlaceholder" [image]="mediaList[0]?.thumbnail || thumbnailPlaceholder"
[preview]="false" [preview]="false"
></ds-media-viewer-image> ></ds-themed-media-viewer-image>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>

View File

@@ -135,7 +135,7 @@ describe('MediaViewerComponent', () => {
it('should display a default, thumbnail', () => { it('should display a default, thumbnail', () => {
const defaultThumbnail = fixture.debugElement.query( const defaultThumbnail = fixture.debugElement.query(
By.css('ds-media-viewer-image') By.css('ds-themed-media-viewer-image')
); );
expect(defaultThumbnail.nativeElement).toBeDefined(); expect(defaultThumbnail.nativeElement).toBeDefined();
}); });

View File

@@ -13,9 +13,8 @@ import { hasValue } from '../../shared/empty.util';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
/** /**
* This componenet renders the media viewers * This component renders the media viewers
*/ */
@Component({ @Component({
selector: 'ds-media-viewer', selector: 'ds-media-viewer',
templateUrl: './media-viewer.component.html', templateUrl: './media-viewer.component.html',

Some files were not shown because too many files have changed in this diff Show More