Merge branch 'main' into making_user-menu-component_themeable

This commit is contained in:
Eike Löhden
2023-10-24 07:53:08 +02:00
committed by GitHub
203 changed files with 8705 additions and 2796 deletions

View File

@@ -25,6 +25,8 @@ jobs:
# See: https://github.com/prince-chrismc/label-merge-conflicts-action
- name: Auto-label PRs with merge conflicts
uses: prince-chrismc/label-merge-conflicts-action@v3
# Ignore any failures -- may occur (randomly?) for older, outdated PRs.
continue-on-error: true
# Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
# Note, the authentication token is created automatically
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token

View File

@@ -0,0 +1,46 @@
# This workflow will attempt to port a merged pull request to
# the branch specified in a "port to" label (if exists)
name: Port merged Pull Request
# Only run for merged PRs against the "main" or maintenance branches
# We allow this to run for `pull_request_target` so that github secrets are available
# (This is required when the PR comes from a forked repo)
on:
pull_request_target:
types: [ closed ]
branches:
- main
- 'dspace-**'
permissions:
contents: write # so action can add comments
pull-requests: write # so action can create pull requests
jobs:
port_pr:
runs-on: ubuntu-latest
# Don't run on closed *unmerged* pull requests
if: github.event.pull_request.merged
steps:
# Checkout code
- uses: actions/checkout@v3
# Port PR to other branch (ONLY if labeled with "port to")
# See https://github.com/korthout/backport-action
- name: Create backport pull requests
uses: korthout/backport-action@v1
with:
# Trigger based on a "port to [branch]" label on PR
# (This label must specify the branch name to port to)
label_pattern: '^port to ([^ ]+)$'
# Title to add to the (newly created) port PR
pull_title: '[Port ${target_branch}] ${pull_title}'
# Description to add to the (newly created) port PR
pull_description: 'Port of #${pull_number} by @${pull_author} to `${target_branch}`.'
# Copy all labels from original PR to (newly created) port PR
# NOTE: The labels matching 'label_pattern' are automatically excluded
copy_labels_pattern: '.*'
# Skip any merge commits in the ported PR. This means only non-merge commits are cherry-picked to the new PR
merge_commits: 'skip'
# Use a personal access token (PAT) to create PR as 'dspace-bot' user.
# A PAT is required in order for the new PR to trigger its own actions (for CI checks)
github_token: ${{ secrets.PR_PORT_TOKEN }}

View File

@@ -2,7 +2,7 @@
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
# Test build:
# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist .
# docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist .
FROM node:18-alpine as build

View File

@@ -157,8 +157,8 @@ DSPACE_UI_SSL => DSPACE_SSL
The same settings can also be overwritten by setting system environment variables instead, E.g.:
```bash
export DSPACE_HOST=api7.dspace.org
export DSPACE_UI_PORT=4200
export DSPACE_HOST=demo.dspace.org
export DSPACE_UI_PORT=4000
```
The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`**
@@ -288,7 +288,7 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con
The test files can be found in the `./cypress/integration/` folder.
Before you can run e2e tests, two things are REQUIRED:
1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time.
1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo/sandbox REST API (https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/), as those sites may have content added/removed at any time.
* After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend.
* If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example:
```

View File

@@ -22,7 +22,7 @@ ui:
# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
rest:
ssl: true
host: api7.dspace.org
host: sandbox.dspace.org
port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server
@@ -208,6 +208,9 @@ languages:
- code: pt-BR
label: Português do Brasil
active: true
- code: sr-lat
label: Srpski (lat)
active: true
- code: fi
label: Suomi
active: true
@@ -292,33 +295,33 @@ themes:
#
# # A theme with a handle property will match the community, collection or item with the given
# # handle, and all collections and/or items within it
# - name: 'custom',
# handle: '10673/1233'
# - name: custom
# handle: 10673/1233
#
# # A theme with a regex property will match the route using a regular expression. If it
# # matches the route for a community or collection it will also apply to all collections
# # and/or items within it
# - name: 'custom',
# regex: 'collections\/e8043bc2.*'
# - name: custom
# regex: collections\/e8043bc2.*
#
# # A theme with a uuid property will match the community, collection or item with the given
# # ID, and all collections and/or items within it
# - name: 'custom',
# uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
# - name: custom
# uuid: 0958c910-2037-42a9-81c7-dca80e3892b4
#
# # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
# # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
# - name: 'custom-A',
# extends: 'custom-B',
# - name: custom-A
# extends: custom-B
# # Any of the matching properties above can be used
# handle: '10673/34'
# handle: 10673/34
#
# - name: 'custom-B',
# extends: 'custom',
# handle: '10673/12'
# - name: custom-B
# extends: custom
# handle: 10673/12
#
# # A theme with only a name will match every route
# name: 'custom'
# name: custom
#
# # This theme will use the default bootstrap styling for DSpace components
# - name: BASE_THEME_NAME
@@ -379,4 +382,4 @@ vocabularies:
# 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'
sortDirection: 'ASC'

View File

@@ -1,5 +1,5 @@
rest:
ssl: true
host: api7.dspace.org
host: sandbox.dspace.org
port: 443
nameSpace: /server

View File

@@ -1,4 +1,3 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
describe('Community List Page', () => {
@@ -13,13 +12,6 @@ describe('Community List Page', () => {
cy.get('[data-test="expand-button"]').click({ multiple: true });
// Analyze <ds-community-list-page> for accessibility issues
// Disable heading-order checks until it is fixed
testA11y('ds-community-list-page',
{
rules: {
'heading-order': { enabled: false }
}
} as Options
);
testA11y('ds-community-list-page');
});
});

View File

@@ -11,8 +11,7 @@ describe('Header', () => {
testA11y({
include: ['ds-header'],
exclude: [
['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
['.dropdownLogin'] // "Log in" link has color contrast issues. Will be fixed in #1149
['#search-navbar-container'] // search in navbar has duplicative ID. Will be fixed in #1174
],
});
});

View File

@@ -1,4 +1,3 @@
import { Options } from 'cypress-axe';
import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
@@ -19,13 +18,16 @@ describe('Item Page', () => {
cy.get('ds-item-page').should('be.visible');
// Analyze <ds-item-page> for accessibility issues
// Disable heading-order checks until it is fixed
testA11y('ds-item-page',
{
rules: {
'heading-order': { enabled: false }
}
} as Options
);
testA11y('ds-item-page');
});
it('should pass accessibility tests on full item page', () => {
cy.visit(ENTITYPAGE + '/full');
// <ds-full-item-page> tag must be loaded
cy.get('ds-full-item-page').should('be.visible');
// Analyze <ds-full-item-page> for accessibility issues
testA11y('ds-full-item-page');
});
});

View File

@@ -1,4 +1,5 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
const page = {
openLoginMenu() {
@@ -123,4 +124,15 @@ describe('Login Modal', () => {
cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist');
});
it('should pass accessibility tests', () => {
cy.visit('/');
page.openLoginMenu();
cy.get('ds-log-in').should('exist');
// Analyze <ds-log-in> for accessibility issues
testA11y('ds-log-in');
});
});

View File

@@ -19,21 +19,7 @@ describe('My DSpace page', () => {
cy.get('.filter-toggle').click({ multiple: true });
// Analyze <ds-my-dspace-page> for accessibility issues
testA11y(
{
include: ['ds-my-dspace-page'],
exclude: [
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
],
},
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
testA11y('ds-my-dspace-page');
});
it('should have a working detailed view that passes accessibility tests', () => {

View File

@@ -1,8 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('PageNotFound', () => {
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
// request an invalid page (UUIDs at root path aren't valid)
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
cy.get('ds-pagenotfound').should('be.visible');
// Analyze <ds-pagenotfound> for accessibility issues
testA11y('ds-pagenotfound');
});
it('should not contain element ds-pagenotfound when navigating to existing page', () => {

View File

@@ -27,21 +27,7 @@ describe('Search Page', () => {
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-search-page> for accessibility issues
testA11y(
{
include: ['ds-search-page'],
exclude: [
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
],
},
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
testA11y('ds-search-page');
});
it('should have a working grid view that passes accessibility tests', () => {

View File

@@ -23,14 +23,14 @@ the Docker compose scripts in this 'docker' folder.
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
```
docker build -t dspace/dspace-angular:dspace-7_x .
docker build -t dspace/dspace-angular:latest .
```
This image is built *automatically* after each commit is made to the `main` branch.
Admins to our DockerHub repo can manually publish with the following command.
```
docker push dspace/dspace-angular:dspace-7_x
docker push dspace/dspace-angular:latest
```
### Dockerfile.dist
@@ -39,7 +39,7 @@ The `Dockerfile.dist` is used to generate a *production* build and runtime envir
```bash
# build the latest image
docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist .
docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist .
```
A default/demo version of this image is built *automatically*.
@@ -101,8 +101,8 @@ 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/).
This allows you to run the Angular UI in *production* mode, pointing it at the demo or sandbox backend
(https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/).
```
docker-compose -f docker/docker-compose-dist.yml pull

View File

@@ -16,7 +16,7 @@ version: "3.7"
services:
dspace-cli:
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}"
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}"
container_name: dspace-cli
environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.

View File

@@ -35,7 +35,7 @@ services:
solr__D__statistics__P__autoCommit: 'false'
depends_on:
- dspacedb
image: dspace/dspace:dspace-7_x-test
image: dspace/dspace:latest-test
networks:
dspacenet:
ports:

View File

@@ -24,10 +24,10 @@ services:
# This is because Server Side Rendering (SSR) currently requires a public URL,
# see this bug: https://github.com/DSpace/dspace-angular/issues/1485
DSPACE_REST_SSL: 'true'
DSPACE_REST_HOST: api7.dspace.org
DSPACE_REST_HOST: sandbox.dspace.org
DSPACE_REST_PORT: 443
DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:dspace-7_x-dist
image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist
build:
context: ..
dockerfile: Dockerfile.dist

View File

@@ -39,7 +39,7 @@ services:
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0'
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}"
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on:
- dspacedb
networks:
@@ -82,7 +82,7 @@ services:
# DSpace Solr container
dspacesolr:
container_name: dspacesolr
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}"
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
# Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on:
- dspace

View File

@@ -24,7 +24,7 @@ services:
DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:dspace-7_x
image: dspace/dspace-angular:${DSPACE_VER:-latest}
build:
context: ..
dockerfile: Dockerfile

View File

@@ -48,7 +48,7 @@ dspace-angular connects to your DSpace installation by using its REST endpoint.
```yaml
rest:
ssl: true
host: api7.dspace.org
host: demo.dspace.org
port: 443
nameSpace: /server
}
@@ -57,7 +57,7 @@ rest:
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
```
DSPACE_REST_SSL=true
DSPACE_REST_HOST=api7.dspace.org
DSPACE_REST_HOST=demo.dspace.org
DSPACE_REST_PORT=443
DSPACE_REST_NAMESPACE=/server
```

View File

@@ -99,6 +99,7 @@
"fast-json-patch": "^3.1.1",
"filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5",
"http-terminator": "^3.2.0",
"isbot": "^3.6.10",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
@@ -116,12 +117,12 @@
"morgan": "^1.10.0",
"ng-mocks": "^14.10.0",
"ng2-file-upload": "1.4.0",
"ng2-nouislider": "^1.8.3",
"ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^15.0.0",
"ngx-pagination": "6.0.3",
"ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^14.0.3",
"nouislider": "^14.6.3",
"nouislider": "^15.7.1",
"pem": "1.14.7",
"prop-types": "^15.8.1",
"react-copy-to-clipboard": "^5.1.0",
@@ -159,11 +160,11 @@
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"axe-core": "^4.7.0",
"axe-core": "^4.7.2",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3",
"cypress": "12.10.0",
"cypress": "12.17.4",
"cypress-axe": "^1.4.0",
"deep-freeze": "0.0.1",
"eslint": "^8.39.0",

View File

@@ -32,6 +32,7 @@ import isbot from 'isbot';
import { createCertificate } from 'pem';
import { createServer } from 'https';
import { json } from 'body-parser';
import { createHttpTerminator } from 'http-terminator';
import { readFileSync } from 'fs';
import { join } from 'path';
@@ -320,22 +321,23 @@ function initCache() {
if (botCacheEnabled()) {
// Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
// See https://www.npmjs.com/package/lru-cache
// When enabled, each page defaults to expiring after 1 day
// When enabled, each page defaults to expiring after 1 day (defined in default-app-config.ts)
botCache = new LRU( {
max: environment.cache.serverSide.botCache.max,
ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day
allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting
ttl: environment.cache.serverSide.botCache.timeToLive,
allowStale: environment.cache.serverSide.botCache.allowStale
});
}
if (anonymousCacheEnabled()) {
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
// may expire pages more frequently.
// When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content)
// When enabled, each page defaults to expiring after 10 seconds (defined in default-app-config.ts)
// to minimize anonymous users seeing out-of-date content
anonymousCache = new LRU( {
max: environment.cache.serverSide.anonymousCache.max,
ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds
allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting
ttl: environment.cache.serverSide.anonymousCache.timeToLive,
allowStale: environment.cache.serverSide.anonymousCache.allowStale
});
}
}
@@ -487,7 +489,7 @@ function saveToCache(req, page: any) {
*/
function hasNotSucceeded(statusCode) {
const rgx = new RegExp(/^20+/);
return !rgx.test(statusCode)
return !rgx.test(statusCode);
}
function retrieveHeaders(response) {
@@ -525,23 +527,46 @@ function serverStarted() {
* @param keys SSL credentials
*/
function createHttpsServer(keys) {
createServer({
const listener = createServer({
key: keys.serviceKey,
cert: keys.certificate
}, app).listen(environment.ui.port, environment.ui.host, () => {
serverStarted();
});
// Graceful shutdown when signalled
const terminator = createHttpTerminator({server: listener});
process.on('SIGINT', () => {
void (async ()=> {
console.debug('Closing HTTPS server on signal');
await terminator.terminate().catch(e => { console.error(e); });
console.debug('HTTPS server closed');
})();
});
}
/**
* Create an HTTP server with the configured port and host.
*/
function run() {
const port = environment.ui.port || 4000;
const host = environment.ui.host || '/';
// Start up the Node server
const server = app();
server.listen(port, host, () => {
const listener = server.listen(port, host, () => {
serverStarted();
});
// Graceful shutdown when signalled
const terminator = createHttpTerminator({server: listener});
process.on('SIGINT', () => {
void (async () => {
console.debug('Closing HTTP server on signal');
await terminator.terminate().catch(e => { console.error(e); });
console.debug('HTTP server closed.');return undefined;
})();
});
}
function start() {

View File

@@ -16,23 +16,23 @@
[submitLabel]="submitLabel"
(submitForm)="onSubmit()">
<div before class="btn-group">
<button (click)="onCancel()"
<button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div>
<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)" type="button" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
</div>
<div between class="btn-group ml-1">
<button *ngIf="!isImpersonated" class="btn btn-primary" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
<button *ngIf="!isImpersonated" class="btn btn-primary" type="button" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-primary" (click)="stopImpersonating()">
<button *ngIf="isImpersonated" class="btn btn-primary" type="button" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
</button>
</div>
<button after class="btn btn-danger delete-button" [disabled]="!(canDelete$ | async)" (click)="delete()">
<button after class="btn btn-danger delete-button" type="button" [disabled]="!(canDelete$ | async)" (click)="delete()">
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
</ds-form>

View File

@@ -36,12 +36,12 @@
[displayCancel]="false"
(submitForm)="onSubmit()">
<div before class="btn-group">
<button (click)="onCancel()"
<button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div>
<div after *ngIf="groupBeingEdited != null" class="btn-group">
<button class="btn btn-danger delete-button" [disabled]="!(canEdit$ | async) || groupBeingEdited.permanent"
(click)="delete()">
(click)="delete()" type="button">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</button>
</div>

View File

@@ -37,7 +37,7 @@ import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../../core/shared/operators';
import { AlertType } from '../../../shared/alert/aletr-type';
import { AlertType } from '../../../shared/alert/alert-type';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue, isNotEmpty, hasValueOperator } from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';

View File

@@ -8,9 +8,9 @@ import {
import { UntypedFormGroup } from '@angular/forms';
import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { take } from 'rxjs/operators';
import { switchMap, take, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest } from 'rxjs';
import { Observable, combineLatest } from 'rxjs';
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
@Component({
@@ -147,30 +147,48 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
* Emit the updated/created schema using the EventEmitter submitForm
*/
onSubmit(): void {
this.registryService.clearMetadataSchemaRequests().subscribe();
this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe(
(schema: MetadataSchema) => {
const values = {
prefix: this.name.value,
namespace: this.namespace.value
};
if (schema == null) {
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), values)).subscribe((newSchema) => {
this.submitForm.emit(newSchema);
this.registryService
.getActiveMetadataSchema()
.pipe(
take(1),
switchMap((schema: MetadataSchema) => {
const metadataValues = {
prefix: this.name.value,
namespace: this.namespace.value,
};
let createOrUpdate$: Observable<MetadataSchema>;
if (schema == null) {
createOrUpdate$ =
this.registryService.createOrUpdateMetadataSchema(
Object.assign(new MetadataSchema(), metadataValues)
);
} else {
const updatedSchema = Object.assign(
new MetadataSchema(),
schema,
{
namespace: metadataValues.namespace,
}
);
createOrUpdate$ =
this.registryService.createOrUpdateMetadataSchema(
updatedSchema
);
}
return createOrUpdate$;
}),
tap(() => {
this.registryService.clearMetadataSchemaRequests().subscribe();
})
)
.subscribe((updatedOrCreatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedOrCreatedSchema);
this.clearFields();
this.registryService.cancelEditMetadataSchema();
});
} else {
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, {
id: schema.id,
prefix: schema.prefix,
namespace: values.namespace,
})).subscribe((updatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedSchema);
});
}
this.clearFields();
this.registryService.cancelEditMetadataSchema();
}
);
}
/**

View File

@@ -41,7 +41,7 @@
</label>
</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" class="mb-0">.</label>{{field.qualifier}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr>
</tbody>

View File

@@ -12,8 +12,7 @@ import { Router } from '@angular/router';
* Represents a non-expandable section in the admin sidebar
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-admin-sidebar-section]',
selector: 'ds-admin-sidebar-section',
templateUrl: './admin-sidebar-section.component.html',
styleUrls: ['./admin-sidebar-section.component.scss'],

View File

@@ -26,10 +26,10 @@
</div>
</li>
<ng-container *ngFor="let section of (sections | async)">
<li *ngFor="let section of (sections | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</ng-container>
</li>
</ul>
</div>
<div class="navbar-nav">

View File

@@ -15,8 +15,7 @@ import { Router } from '@angular/router';
* Represents a expandable section in the sidebar
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-expandable-admin-sidebar-section]',
selector: 'ds-expandable-admin-sidebar-section',
templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor]

View File

@@ -161,7 +161,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
this.value = '';
}
if (typeof params.startsWith === 'string'){
if (params.startsWith === undefined || params.startsWith === '') {
this.startsWith = undefined;
}
if (typeof params.startsWith === 'string'){
this.startsWith = params.startsWith.trim();
}

View File

@@ -98,9 +98,8 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> imp
// retrieve all entity types to populate the dropdowns selection
entities$.subscribe((entityTypes: ItemType[]) => {
entityTypes
.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE)
.forEach((type: ItemType, index: number) => {
entityTypes = entityTypes.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE);
entityTypes.forEach((type: ItemType, index: number) => {
this.entityTypeSelection.add({
disabled: false,
label: type.label,
@@ -112,7 +111,7 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> imp
}
});
this.formModel = [...collectionFormModels, this.entityTypeSelection];
this.formModel = entityTypes.length === 0 ? collectionFormModels : [...collectionFormModels, this.entityTypeSelection];
super.ngOnInit();
this.chd.detectChanges();

View File

@@ -34,9 +34,6 @@
</ds-comcol-page-content>
</header>
<ds-dso-edit-menu></ds-dso-edit-menu>
<div class="pl-2 space-children-mr">
<ds-dso-page-subscription-button [dso]="collection"></ds-dso-page-subscription-button>
</div>
</div>
<section class="comcol-page-browse-section">
<!-- Browse-By Links -->

View File

@@ -8,7 +8,7 @@ import { ItemTemplateDataService } from '../../core/data/item-template-data.serv
import { getCollectionEditRoute } from '../collection-page-routing-paths';
import { Item } from '../../core/shared/item.model';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { AlertType } from '../../shared/alert/aletr-type';
import { AlertType } from '../../shared/alert/alert-type';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
@Component({

View File

@@ -1,4 +1,4 @@
<div class="container">
<h2>{{ 'communityList.title' | translate }}</h2>
<h1>{{ 'communityList.title' | translate }}</h1>
<ds-themed-community-list></ds-themed-community-list>
</div>

View File

@@ -25,7 +25,7 @@ import { ShowMoreFlatNode } from './show-more-flat-node.model';
import { FindListOptions } from '../core/data/find-list-options.model';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
// Helper method to combine an flatten an array of observables of flatNode arrays
// Helper method to combine and flatten an array of observables of flatNode arrays
export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> =>
observableCombineLatest([...obsList]).pipe(
map((matrix: any[][]) => [].concat(...matrix)),
@@ -199,7 +199,7 @@ export class CommunityListService {
* Transforms a community in a list of FlatNodes containing firstly a flatnode of the community itself,
* followed by flatNodes of its possible subcommunities and collection
* It gets called recursively for each subcommunity to add its subcommunities and collections to the list
* Number of subcommunities and collections added, is dependant on the current page the parent is at for respectively subcommunities and collections.
* Number of subcommunities and collections added, is dependent on the current page the parent is at for respectively subcommunities and collections.
* @param community Community being transformed
* @param level Depth of the community in the list, subcommunities and collections go one level deeper
* @param parent Flatnode of the parent community
@@ -275,7 +275,7 @@ export class CommunityListService {
/**
* Checks if a community has subcommunities or collections by querying the respective services with a pageSize = 0
* Returns an observable that combines the result.payload.totalElements fo the observables that the
* Returns an observable that combines the result.payload.totalElements of the observables that the
* respective services return when queried
* @param community Community being checked whether it is expandable (if it has subcommunities or collections)
*/

View File

@@ -1,5 +1,5 @@
<ds-themed-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-themed-loading"></ds-themed-loading>
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl">
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl" [trackBy]="trackBy">
<!-- This is the tree node template for show more node -->
<cdk-tree-node *cdkTreeNodeDef="let node; when: isShowMore" cdkTreeNodePadding
class="example-tree-node show-more-node">
@@ -34,13 +34,13 @@
aria-hidden="true"></span>
</button>
<div class="d-flex flex-row">
<h5 class="align-middle pt-2">
<span class="align-middle pt-2 lead">
<a [routerLink]="node.route" class="lead">
{{ dsoNameService.getName(node.payload) }}
</a>
<span class="pr-2">&nbsp;</span>
<span *ngIf="node.payload.archivedItemsCount >= 0" class="badge badge-pill badge-secondary align-top archived-items-lead">{{node.payload.archivedItemsCount}}</span>
</h5>
</span>
</div>
</div>
<ds-truncatable [id]="node.id">

View File

@@ -28,10 +28,9 @@ export class CommunityListComponent implements OnInit, OnDestroy {
treeControl = new FlatTreeControl<FlatNode>(
(node: FlatNode) => node.level, (node: FlatNode) => true
);
dataSource: CommunityListDatasource;
paginationConfig: FindListOptions;
trackBy = (index, node: FlatNode) => node.id;
constructor(
protected communityListService: CommunityListService,
@@ -58,18 +57,28 @@ export class CommunityListComponent implements OnInit, OnDestroy {
this.communityListService.saveCommunityListStateToStore(this.expandedNodes, this.loadingNode);
}
// whether or not this node has children (subcommunities or collections)
/**
* Whether this node has children (subcommunities or collections)
* @param _
* @param node
*/
hasChild(_: number, node: FlatNode) {
return node.isExpandable$;
}
// whether or not it is a show more node (contains no data, but is indication that there are more topcoms, subcoms or collections
/**
* Whether this is a show more node that contains no data, but indicates that there is
* one or more community or collection.
* @param _
* @param node
*/
isShowMore(_: number, node: FlatNode) {
return node.isShowMoreNode;
}
/**
* Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree so this node is expanded
* Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree
* so this node is expanded
* @param node Node we want to expand
*/
toggleExpanded(node: FlatNode) {
@@ -92,9 +101,12 @@ export class CommunityListComponent implements OnInit, OnDestroy {
/**
* Makes sure the next page of a node is added to the tree (top community, sub community of collection)
* > Finds its parent (if not top community) and increases its corresponding collection/subcommunity currentPage
* > Reloads tree with new page added to corresponding top community lis, sub community list or collection list
* @param node The show more node indicating whether it's an increase in top communities, sub communities or collections
* > Finds its parent (if not top community) and increases its corresponding collection/subcommunity
* currentPage
* > Reloads tree with new page added to corresponding top community lis, sub community list or
* collection list
* @param node The show more node indicating whether it's an increase in top communities, sub communities
* or collections
*/
getNextPage(node: FlatNode): void {
this.loadingNode = node;

View File

@@ -1,6 +1,6 @@
/**
* The show more links in the community tree are also represented by a flatNode so we know where in
* the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link)
* the tree it should be rendered and who its parent is (needed for the action resulting in clicking this link)
*/
export class ShowMoreFlatNode {
}

View File

@@ -21,9 +21,6 @@
</ds-comcol-page-content>
</header>
<ds-dso-edit-menu></ds-dso-edit-menu>
<div class="pl-2 space-children-mr">
<ds-dso-page-subscription-button [dso]="communityPayload"></ds-dso-page-subscription-button>
</div>
</div>
<section class="comcol-page-browse-section">

View File

@@ -152,12 +152,12 @@ export class AuthInterceptor implements HttpInterceptor {
let authMethodModel: AuthMethod;
if (splittedRealm.length === 1) {
authMethodModel = new AuthMethod(methodName);
authMethodModel = new AuthMethod(methodName, Number(j));
authMethodModels.push(authMethodModel);
} else if (splittedRealm.length > 1) {
let location = splittedRealm[1];
location = this.parseLocation(location);
authMethodModel = new AuthMethod(methodName, location);
authMethodModel = new AuthMethod(methodName, Number(j), location);
authMethodModels.push(authMethodModel);
}
}
@@ -165,7 +165,7 @@ export class AuthInterceptor implements HttpInterceptor {
// make sure the email + password login component gets rendered first
authMethodModels = this.sortAuthMethods(authMethodModels);
} else {
authMethodModels.push(new AuthMethod(AuthMethodType.Password));
authMethodModels.push(new AuthMethod(AuthMethodType.Password, 0));
}
return authMethodModels;

View File

@@ -598,9 +598,9 @@ describe('authReducer', () => {
authMethods: [],
idle: false
};
const authMethods = [
new AuthMethod(AuthMethodType.Password),
new AuthMethod(AuthMethodType.Shibboleth, 'location')
const authMethods: AuthMethod[] = [
new AuthMethod(AuthMethodType.Password, 0),
new AuthMethod(AuthMethodType.Shibboleth, 1, 'location'),
];
const action = new RetrieveAuthMethodsSuccessAction(authMethods);
const newState = authReducer(initialState, action);
@@ -632,7 +632,7 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)],
authMethods: [new AuthMethod(AuthMethodType.Password, 0)],
idle: false
};
expect(newState).toEqual(state);

View File

@@ -236,7 +236,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
return Object.assign({}, state, {
loading: false,
blocking: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
authMethods: [new AuthMethod(AuthMethodType.Password, 0)]
});
case AuthActionTypes.SET_REDIRECT_URL:

View File

@@ -2,11 +2,12 @@ import { AuthMethodType } from './auth.method-type';
export class AuthMethod {
authMethodType: AuthMethodType;
position: number;
location?: string;
// isStandalonePage? = true;
constructor(authMethodName: string, position: number, location?: string) {
this.position = position;
constructor(authMethodName: string, location?: string) {
switch (authMethodName) {
case 'ip': {
this.authMethodType = AuthMethodType.Ip;

View File

@@ -7,6 +7,7 @@ import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
import { RouteEffects } from './services/route.effects';
import { RouterEffects } from './router/router.effects';
import { MenuEffects } from '../shared/menu/menu.effects';
export const coreEffects = [
RequestEffects,
@@ -18,4 +19,5 @@ export const coreEffects = [
ObjectUpdatesEffects,
RouteEffects,
RouterEffects,
MenuEffects,
];

View File

@@ -1,6 +1,6 @@
import { Store, StoreModule } from '@ngrx/store';
import { cold, getTestScheduler } from 'jasmine-marbles';
import { EMPTY, of as observableOf } from 'rxjs';
import { EMPTY, Observable, of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock';
@@ -638,4 +638,87 @@ describe('RequestService', () => {
expect(done$).toBeObservable(cold('-----(t|)', { t: true }));
}));
});
describe('setStaleByHref', () => {
const uuid = 'c574a42c-4818-47ac-bbe1-6c3cd622c81f';
const href = 'https://rest.api/some/object';
const freshRE: any = {
request: { uuid, href },
state: RequestEntryState.Success
};
const staleRE: any = {
request: { uuid, href },
state: RequestEntryState.SuccessStale
};
it(`should call getByHref to retrieve the RequestEntry matching the href`, () => {
spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE));
service.setStaleByHref(href);
expect(service.getByHref).toHaveBeenCalledWith(href);
});
it(`should dispatch a RequestStaleAction for the RequestEntry returned by getByHref`, (done: DoneFn) => {
spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE));
spyOn(store, 'dispatch');
service.setStaleByHref(href).subscribe(() => {
const requestStaleAction = new RequestStaleAction(uuid);
requestStaleAction.lastUpdated = jasmine.any(Number) as any;
expect(store.dispatch).toHaveBeenCalledWith(requestStaleAction);
done();
});
});
it(`should emit true when the request in the store is stale`, () => {
spyOn(service, 'getByHref').and.returnValue(cold('a-b', {
a: freshRE,
b: staleRE
}));
const result$ = service.setStaleByHref(href);
expect(result$).toBeObservable(cold('--(c|)', { c: true }));
});
});
describe('setStaleByHrefSubstring', () => {
let dispatchSpy: jasmine.Spy;
let getByUUIDSpy: jasmine.Spy;
beforeEach(() => {
dispatchSpy = spyOn(store, 'dispatch');
getByUUIDSpy = spyOn(service, 'getByUUID').and.callThrough();
});
describe('with an empty/no matching requests in the state', () => {
it('should return true', () => {
const done$: Observable<boolean> = service.setStaleByHrefSubstring('https://rest.api/endpoint/selfLink');
expect(done$).toBeObservable(cold('(a|)', { a: true }));
});
});
describe('with a matching request in the state', () => {
beforeEach(() => {
const state = Object.assign({}, initialState, {
core: Object.assign({}, initialState.core, {
'index': {
'get-request/href-to-uuid': {
'https://rest.api/endpoint/selfLink': '5f2a0d2a-effa-4d54-bd54-5663b960f9eb'
}
}
})
});
mockStore.setState(state);
});
it('should return an Observable that emits true as soon as the request is stale', () => {
dispatchSpy.and.callFake(() => { /* empty */ }); // don't actually set as stale
getByUUIDSpy.and.returnValue(cold('a-b--c--d-', { // but fake the state in the cache
a: { state: RequestEntryState.ResponsePending },
b: { state: RequestEntryState.Success },
c: { state: RequestEntryState.SuccessStale },
d: { state: RequestEntryState.Error },
}));
const done$: Observable<boolean> = service.setStaleByHrefSubstring('https://rest.api/endpoint/selfLink');
expect(done$).toBeObservable(cold('-----(a|)', { a: true }));
});
});
});
});

View File

@@ -2,8 +2,8 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { filter, map, take, tap } from 'rxjs/operators';
import { Observable, from as observableFrom } from 'rxjs';
import { filter, find, map, mergeMap, switchMap, take, tap, toArray } from 'rxjs/operators';
import cloneDeep from 'lodash/cloneDeep';
import { hasValue, isEmpty, isNotEmpty, hasNoValue } from '../../shared/empty.util';
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
@@ -16,7 +16,7 @@ import {
RequestExecuteAction,
RequestStaleAction
} from './request.actions';
import { GetRequest} from './request.models';
import { GetRequest } from './request.models';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { coreSelector } from '../core.selectors';
@@ -300,22 +300,42 @@ export class RequestService {
* Set all requests that match (part of) the href to stale
*
* @param href A substring of the request(s) href
* @return Returns an observable emitting whether or not the cache is removed
* @return Returns an observable emitting when those requests are all stale
*/
setStaleByHrefSubstring(href: string): Observable<boolean> {
this.store.pipe(
const requestUUIDs$ = this.store.pipe(
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
take(1)
).subscribe((uuids: string[]) => {
);
requestUUIDs$.subscribe((uuids: string[]) => {
for (const uuid of uuids) {
this.store.dispatch(new RequestStaleAction(uuid));
}
});
this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0);
return this.store.pipe(
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
map((uuids) => isEmpty(uuids))
// emit true after all requests are stale
return requestUUIDs$.pipe(
switchMap((uuids: string[]) => {
if (isEmpty(uuids)) {
// if there were no matching requests, emit true immediately
return [true];
} else {
// otherwise emit all request uuids in order
return observableFrom(uuids).pipe(
// retrieve the RequestEntry for each uuid
mergeMap((uuid: string) => this.getByUUID(uuid)),
// check whether it is undefined or stale
map((request: RequestEntry) => hasNoValue(request) || isStale(request.state)),
// if it is, complete
find((stale: boolean) => stale === true),
// after all observables above are completed, emit them as a single array
toArray(),
// when the array comes in, emit true
map(() => true)
);
}
})
);
}
@@ -331,7 +351,29 @@ export class RequestService {
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1),
);
);
}
/**
* Mark a request as stale
* @param href the href of the request
* @return an Observable that will emit true once the Request becomes stale
*/
setStaleByHref(href: string): Observable<boolean> {
const requestEntry$ = this.getByHref(href);
requestEntry$.pipe(
map((re: RequestEntry) => re.request.uuid),
take(1),
).subscribe((uuid: string) => {
this.store.dispatch(new RequestStaleAction(uuid));
});
return requestEntry$.pipe(
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1)
);
}
/**
@@ -344,10 +386,10 @@ export class RequestService {
// if it's not a GET request
if (request.method !== RestRequestMethod.GET) {
return true;
// if it is a GET request, check it isn't pending
// if it is a GET request, check it isn't pending
} else if (this.isPending(request)) {
return false;
// if it is pending, check if we're allowed to use a cached version
// if it is pending, check if we're allowed to use a cached version
} else if (!useCachedVersionIfAvailable) {
return true;
} else {

View File

@@ -1,16 +1,18 @@
import { RootDataService } from './root-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { Observable, of } from 'rxjs';
import {
createSuccessfulRemoteDataObject$,
createFailedRemoteDataObject$
} from '../../shared/remote-data.utils';
import { Observable } from 'rxjs';
import { RemoteData } from './remote-data';
import { Root } from './root.model';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { cold } from 'jasmine-marbles';
describe('RootDataService', () => {
let service: RootDataService;
let halService: HALEndpointService;
let restService;
let requestService;
let rootEndpoint;
let findByHrefSpy;
@@ -19,10 +21,10 @@ describe('RootDataService', () => {
halService = jasmine.createSpyObj('halService', {
getRootHref: rootEndpoint,
});
restService = jasmine.createSpyObj('halService', {
get: jasmine.createSpy('get'),
});
service = new RootDataService(null, null, null, halService, restService);
requestService = jasmine.createSpyObj('requestService', [
'setStaleByHref',
]);
service = new RootDataService(requestService, null, null, halService);
findByHrefSpy = spyOn(service as any, 'findByHref');
findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
@@ -47,12 +49,8 @@ describe('RootDataService', () => {
let result$: Observable<boolean>;
it('should return observable of true when root endpoint is available', () => {
const mockResponse = {
statusCode: 200,
statusText: 'OK'
} as RawRestResponse;
spyOn(service, 'findRoot').and.returnValue(createSuccessfulRemoteDataObject$<Root>({} as any));
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', {
@@ -61,12 +59,8 @@ describe('RootDataService', () => {
});
it('should return observable of false when root endpoint is not available', () => {
const mockResponse = {
statusCode: 500,
statusText: 'Internal Server Error'
} as RawRestResponse;
spyOn(service, 'findRoot').and.returnValue(createFailedRemoteDataObject$<Root>('500'));
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', {
@@ -75,4 +69,12 @@ describe('RootDataService', () => {
});
});
describe(`invalidateRootCache`, () => {
it(`should set the cached root request to stale`, () => {
service.invalidateRootCache();
expect(halService.getRootHref).toHaveBeenCalled();
expect(requestService.setStaleByHref).toHaveBeenCalledWith(rootEndpoint);
});
});
});

View File

@@ -7,12 +7,11 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable, of as observableOf } from 'rxjs';
import { RemoteData } from './remote-data';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { catchError, map } from 'rxjs/operators';
import { BaseDataService } from './base/base-data.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { dataService } from './base/data-service.decorator';
import { getFirstCompletedRemoteData } from '../shared/operators';
/**
* A service to retrieve the {@link Root} object from the REST API.
@@ -25,7 +24,6 @@ export class RootDataService extends BaseDataService<Root> {
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected restService: DspaceRestService,
) {
super('', requestService, rdbService, objectCache, halService, 6 * 60 * 60 * 1000);
}
@@ -34,12 +32,13 @@ export class RootDataService extends BaseDataService<Root> {
* Check if root endpoint is available
*/
checkServerAvailability(): Observable<boolean> {
return this.restService.get(this.halService.getRootHref()).pipe(
return this.findRoot().pipe(
catchError((err ) => {
console.error(err);
return observableOf(false);
}),
map((res: RawRestResponse) => res.statusCode === 200)
getFirstCompletedRemoteData(),
map((rootRd: RemoteData<Root>) => rootRd.statusCode === 200)
);
}
@@ -60,6 +59,6 @@ export class RootDataService extends BaseDataService<Root> {
* Set to sale the root endpoint cache hit
*/
invalidateRootCache() {
this.requestService.setStaleByHrefSubstring(this.halService.getRootHref());
this.requestService.setStaleByHref(this.halService.getRootHref());
}
}

View File

@@ -1,68 +1,86 @@
import { ServerCheckGuard } from './server-check.guard';
import { Router } from '@angular/router';
import { Router, NavigationStart, UrlTree, NavigationEnd, RouterEvent } from '@angular/router';
import { of } from 'rxjs';
import { take } from 'rxjs/operators';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
import { of, ReplaySubject } from 'rxjs';
import { RootDataService } from '../data/root-data.service';
import { TestScheduler } from 'rxjs/testing';
import SpyObj = jasmine.SpyObj;
describe('ServerCheckGuard', () => {
let guard: ServerCheckGuard;
let router: SpyObj<Router>;
let router: Router;
const eventSubject = new ReplaySubject<RouterEvent>(1);
let rootDataServiceStub: SpyObj<RootDataService>;
rootDataServiceStub = jasmine.createSpyObj('RootDataService', {
checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache')
});
router = jasmine.createSpyObj('Router', {
navigateByUrl: jasmine.createSpy('navigateByUrl')
});
let testScheduler: TestScheduler;
let redirectUrlTree: UrlTree;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
rootDataServiceStub = jasmine.createSpyObj('RootDataService', {
checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache'),
findRoot: jasmine.createSpy('findRoot')
});
redirectUrlTree = new UrlTree();
router = {
events: eventSubject.asObservable(),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
parseUrl: jasmine.createSpy('parseUrl').and.returnValue(redirectUrlTree)
} as any;
guard = new ServerCheckGuard(router, rootDataServiceStub);
});
afterEach(() => {
router.navigateByUrl.calls.reset();
rootDataServiceStub.invalidateRootCache.calls.reset();
});
it('should be created', () => {
expect(guard).toBeTruthy();
});
describe('when root endpoint has succeeded', () => {
describe('when root endpoint request has succeeded', () => {
beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(true));
});
it('should not redirect to error page', () => {
guard.canActivateChild({} as any, {} as any).pipe(
take(1)
).subscribe((canActivate: boolean) => {
expect(canActivate).toEqual(true);
expect(rootDataServiceStub.invalidateRootCache).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
it('should return true', () => {
testScheduler.run(({ expectObservable }) => {
const result$ = guard.canActivateChild({} as any, {} as any);
expectObservable(result$).toBe('(a|)', { a: true });
});
});
});
describe('when root endpoint has not succeeded', () => {
describe('when root endpoint request has not succeeded', () => {
beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(false));
});
it('should redirect to error page', () => {
guard.canActivateChild({} as any, {} as any).pipe(
take(1)
).subscribe((canActivate: boolean) => {
expect(canActivate).toEqual(false);
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(getPageInternalServerErrorRoute());
it('should return a UrlTree with the route to the 500 error page', () => {
testScheduler.run(({ expectObservable }) => {
const result$ = guard.canActivateChild({} as any, {} as any);
expectObservable(result$).toBe('(b|)', { b: redirectUrlTree });
});
expect(router.parseUrl).toHaveBeenCalledWith('/500');
});
});
describe(`listenForRouteChanges`, () => {
it(`should retrieve the root endpoint, without using the cache, when the method is first called`, () => {
testScheduler.run(() => {
guard.listenForRouteChanges();
expect(rootDataServiceStub.findRoot).toHaveBeenCalledWith(false);
});
});
it(`should invalidate the root cache on every NavigationStart event`, () => {
testScheduler.run(() => {
guard.listenForRouteChanges();
eventSubject.next(new NavigationStart(1,''));
eventSubject.next(new NavigationEnd(1,'', ''));
eventSubject.next(new NavigationStart(2,''));
eventSubject.next(new NavigationEnd(2,'', ''));
eventSubject.next(new NavigationStart(3,''));
});
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -1,8 +1,15 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router';
import {
ActivatedRouteSnapshot,
CanActivateChild,
Router,
RouterStateSnapshot,
UrlTree,
NavigationStart
} from '@angular/router';
import { Observable } from 'rxjs';
import { take, tap } from 'rxjs/operators';
import { take, map, filter } from 'rxjs/operators';
import { RootDataService } from '../data/root-data.service';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
@@ -23,17 +30,38 @@ export class ServerCheckGuard implements CanActivateChild {
*/
canActivateChild(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> {
state: RouterStateSnapshot
): Observable<boolean | UrlTree> {
return this.rootDataService.checkServerAvailability().pipe(
take(1),
tap((isAvailable: boolean) => {
map((isAvailable: boolean) => {
if (!isAvailable) {
this.rootDataService.invalidateRootCache();
this.router.navigateByUrl(getPageInternalServerErrorRoute());
return this.router.parseUrl(getPageInternalServerErrorRoute());
} else {
return true;
}
})
);
}
/**
* Listen to all router events. Every time a new navigation starts, invalidate the cache
* for the root endpoint. That way we retrieve it once per routing operation to ensure the
* backend is not down. But if the guard is called multiple times during the same routing
* operation, the cached version is used.
*/
listenForRouteChanges(): void {
// we'll always be too late for the first NavigationStart event with the router subscribe below,
// so this statement is for the very first route operation. A `find` without using the cache,
// rather than an invalidateRootCache, because invalidating as the app is bootstrapping can
// break other features
this.rootDataService.findRoot(false);
this.router.events.pipe(
filter(event => event instanceof NavigationStart),
).subscribe(() => {
this.rootDataService.invalidateRootCache();
});
}
}

View File

@@ -38,8 +38,8 @@ export class BrowserHardRedirectService extends HardRedirectService {
/**
* Get the origin of the current URL
* i.e. <scheme> "://" <hostname> [ ":" <port> ]
* e.g. if the URL is https://demo7.dspace.org/search?query=test,
* the origin would be https://demo7.dspace.org
* e.g. if the URL is https://demo.dspace.org/search?query=test,
* the origin would be https://demo.dspace.org
*/
getCurrentOrigin(): string {
return this.location.origin;

View File

@@ -25,8 +25,8 @@ export abstract class HardRedirectService {
/**
* Get the origin of the current URL
* i.e. <scheme> "://" <hostname> [ ":" <port> ]
* e.g. if the URL is https://demo7.dspace.org/search?query=test,
* the origin would be https://demo7.dspace.org
* e.g. if the URL is https://demo.dspace.org/search?query=test,
* the origin would be https://demo.dspace.org
*/
abstract getCurrentOrigin(): string;
}

View File

@@ -69,8 +69,8 @@ export class ServerHardRedirectService extends HardRedirectService {
/**
* Get the origin of the current URL
* i.e. <scheme> "://" <hostname> [ ":" <port> ]
* e.g. if the URL is https://demo7.dspace.org/search?query=test,
* the origin would be https://demo7.dspace.org
* e.g. if the URL is https://demo.dspace.org/search?query=test,
* the origin would be https://demo.dspace.org
*/
getCurrentOrigin(): string {
return this.req.protocol + '://' + this.req.headers.host;

View File

@@ -29,6 +29,18 @@ export class WorkspaceitemSectionUploadFileObject {
value: string;
};
/**
* The file format information
*/
format: {
shortDescription: string,
description: string,
mimetype: string,
supportLevel: string,
internal: boolean,
type: string
};
/**
* The file url
*/

View File

@@ -1,5 +1,5 @@
import { Component, Inject, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { AlertType } from '../../shared/alert/aletr-type';
import { AlertType } from '../../shared/alert/alert-type';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { DsoEditMetadataForm } from './dso-edit-metadata-form';
import { map } from 'rxjs/operators';

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ElementRef } from '@angular/core';
import { ContextHelpService } from '../../shared/context-help.service';
import { Observable } from 'rxjs';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
/**
@@ -15,12 +15,23 @@ import { map } from 'rxjs/operators';
export class ContextHelpToggleComponent implements OnInit {
buttonVisible$: Observable<boolean>;
subscriptions: Subscription[] = [];
constructor(
private contextHelpService: ContextHelpService,
) { }
protected elRef: ElementRef,
protected contextHelpService: ContextHelpService,
) {
}
ngOnInit(): void {
this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0));
this.subscriptions.push(this.buttonVisible$.subscribe((showContextHelpToggle: boolean) => {
if (showContextHelpToggle) {
this.elRef.nativeElement.classList.remove('d-none');
} else {
this.elRef.nativeElement.classList.add('d-none');
}
}));
}
onClick() {

View File

@@ -7,12 +7,12 @@
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
<ds-themed-search-navbar></ds-themed-search-navbar>
<ds-lang-switch></ds-lang-switch>
<ds-themed-lang-switch></ds-themed-lang-switch>
<ds-context-help-toggle></ds-context-help-toggle>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()"
<div *ngIf="isXsOrSm$ | async" class="pl-2">
<button class="navbar-toggler px-0" type="button" (click)="toggleNavbar()"
aria-controls="collapsingNav"
aria-expanded="false" [attr.aria-label]="'nav.toggle' | translate">
<span class="navbar-toggler-icon fas fa-bars fa-fw" aria-hidden="true"></span>

View File

@@ -20,3 +20,8 @@
}
}
.navbar {
display: flex;
gap: calc(var(--bs-spacer) / 3);
align-items: center;
}

View File

@@ -10,6 +10,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MenuService } from '../shared/menu/menu.service';
import { MenuServiceStub } from '../shared/testing/menu-service.stub';
import { HostWindowService } from '../shared/host-window.service';
import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub';
let comp: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;
@@ -26,6 +28,7 @@ describe('HeaderComponent', () => {
ReactiveFormsModule],
declarations: [HeaderComponent],
providers: [
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: MenuService, useValue: menuService }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -40,7 +43,7 @@ describe('HeaderComponent', () => {
fixture = TestBed.createComponent(HeaderComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('when the toggle button is clicked', () => {

View File

@@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { MenuService } from '../shared/menu/menu.service';
import { MenuID } from '../shared/menu/menu-id.model';
import { HostWindowService } from '../shared/host-window.service';
/**
* Represents the header with the logo and simple navigation
@@ -11,20 +12,25 @@ import { MenuID } from '../shared/menu/menu-id.model';
styleUrls: ['header.component.scss'],
templateUrl: 'header.component.html',
})
export class HeaderComponent {
export class HeaderComponent implements OnInit {
/**
* Whether user is authenticated.
* @type {Observable<string>}
*/
public isAuthenticated: Observable<boolean>;
public showAuth = false;
public isXsOrSm$: Observable<boolean>;
menuID = MenuID.PUBLIC;
constructor(
private menuService: MenuService
protected menuService: MenuService,
protected windowService: HostWindowService,
) {
}
ngOnInit(): void {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
public toggleNavbar(): void {
this.menuService.toggleMenu(this.menuID);
}

View File

@@ -3,7 +3,7 @@ import { Component, Input } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { HealthComponent } from '../../models/health-component.model';
import { AlertType } from '../../../shared/alert/aletr-type';
import { AlertType } from '../../../shared/alert/alert-type';
/**
* A component to render a "health component" object.

View File

@@ -1,6 +1,6 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../core/shared/item.model';
import { AlertType } from '../../shared/alert/aletr-type';
import { AlertType } from '../../shared/alert/alert-type';
@Component({
selector: 'ds-item-alerts',

View File

@@ -25,7 +25,7 @@
<div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center">
<div class="text-center w-100">
<div class="btn-group relationship-action-buttons">
<a *ngIf="bitstreamDownloadUrl != null" [href]="bitstreamDownloadUrl"
<a *ngIf="bitstreamDownloadUrl != null" [routerLink]="bitstreamDownloadUrl"
class="btn btn-outline-primary btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}"
[attr.data-test]="'download-button' | dsBrowserOnly">

View File

@@ -13,6 +13,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat
import { getBitstreamDownloadRoute } from '../../../../app-routing-paths';
import { By } from '@angular/platform-browser';
import { BrowserOnlyMockPipe } from '../../../../shared/testing/browser-only-mock.pipe';
import { RouterTestingModule } from '@angular/router/testing';
let comp: ItemEditBitstreamComponent;
let fixture: ComponentFixture<ItemEditBitstreamComponent>;
@@ -72,7 +73,10 @@ describe('ItemEditBitstreamComponent', () => {
);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
imports: [
RouterTestingModule.withRoutes([]),
TranslateModule.forRoot(),
],
declarations: [
ItemEditBitstreamComponent,
VarDirective,

View File

@@ -5,7 +5,7 @@ import { Item } from '../../../core/shared/item.model';
import { map } from 'rxjs/operators';
import { getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { ActivatedRoute } from '@angular/router';
import { AlertType } from '../../../shared/alert/aletr-type';
import { AlertType } from '../../../shared/alert/alert-type';
@Component({
selector: 'ds-item-version-history',

View File

@@ -15,7 +15,7 @@ import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { AlertType } from '../../../shared/alert/aletr-type';
import { AlertType } from '../../../shared/alert/alert-type';
import { Item } from '../../../core/shared/item.model';
import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service';

View File

@@ -1,6 +1,6 @@
<h2 class="item-page-title-field">
<h1 class="item-page-title-field">
<div *ngIf="item.firstMetadataValue('dspace.entity.type') as type" class="d-inline">
{{ type.toLowerCase() + '.page.titleprefix' | translate }}
</div>
<span class="dont-break-out">{{ dsoNameService.getName(item) }}</span>
</h2>
</h1>

View File

@@ -5,8 +5,7 @@ import { RemoteData } from '../../../../core/data/remote-data';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../core/shared/item.model';
import {
getFirstSucceededRemoteDataPayload,
getFirstSucceededRemoteData
getFirstCompletedRemoteData
} from '../../../../core/shared/operators';
import { hasValue } from '../../../../shared/empty.util';
import { InjectionToken } from '@angular/core';
@@ -77,24 +76,42 @@ export const relationsToItems = (thisId: string) =>
* @param {string} thisId The item's id of which the relations belong to
* @returns {(source: Observable<Relationship[]>) => Observable<Item[]>}
*/
export const paginatedRelationsToItems = (thisId: string) =>
(source: Observable<RemoteData<PaginatedList<Relationship>>>): Observable<RemoteData<PaginatedList<Item>>> =>
export const paginatedRelationsToItems = (thisId: string) => (source: Observable<RemoteData<PaginatedList<Relationship>>>): Observable<RemoteData<PaginatedList<Item>>> =>
source.pipe(
getFirstSucceededRemoteData(),
getFirstCompletedRemoteData(),
switchMap((relationshipsRD: RemoteData<PaginatedList<Relationship>>) => {
return observableCombineLatest(
relationshipsRD.payload.page.map((rel: Relationship) =>
observableCombineLatest([
rel.leftItem.pipe(getFirstSucceededRemoteDataPayload()),
rel.rightItem.pipe(getFirstSucceededRemoteDataPayload())]
rel.leftItem.pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<Item>) => {
if (rd.hasSucceeded) {
return rd.payload;
} else {
return null;
}
})
),
rel.rightItem.pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<Item>) => {
if (rd.hasSucceeded) {
return rd.payload;
} else {
return null;
}
})
),
]
)
)).pipe(
)
).pipe(
map((arr) =>
arr
.map(([leftItem, rightItem]) => {
if (leftItem.id === thisId) {
arr.map(([leftItem, rightItem]) => {
if (hasValue(leftItem) && leftItem.id === thisId) {
return rightItem;
} else if (rightItem.id === thisId) {
} else if (hasValue(rightItem) && rightItem.id === thisId) {
return leftItem;
}
})

View File

@@ -2,5 +2,6 @@
[fixedFilterQuery]="fixedFilter"
[configuration]="configuration"
[searchEnabled]="searchEnabled"
[sideBarWidth]="sideBarWidth">
[sideBarWidth]="sideBarWidth"
[showCsvExport]="true">
</ds-configuration-search-page>

View File

@@ -23,7 +23,7 @@ import { PaginatedList } from '../../core/data/paginated-list.model';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { VersionHistoryDataService } from '../../core/data/version-history-data.service';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import { AlertType } from '../../shared/alert/aletr-type';
import { AlertType } from '../../shared/alert/alert-type';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { hasValue, hasValueOperator } from '../../shared/empty.util';
import { PaginationService } from '../../core/pagination/pagination.service';

View File

@@ -12,7 +12,7 @@ import {
} from '../../../core/shared/operators';
import { map, startWith, switchMap } from 'rxjs/operators';
import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
import { AlertType } from '../../../shared/alert/aletr-type';
import { AlertType } from '../../../shared/alert/alert-type';
import { getItemPageRoute } from '../../item-page-routing-paths';
@Component({

View File

@@ -14,9 +14,9 @@
</a>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
class="m-0 shadow-none border-top-0 dropdown-menu show">
<ng-container *ngFor="let subSection of (subSections$ | async)">
<li *ngFor="let subSection of (subSections$ | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</ng-container>
</li>
</ul>
</div>

View File

@@ -4,7 +4,6 @@ import { MenuService } from '../../shared/menu/menu.service';
import { slide } from '../../shared/animations/slide';
import { first } from 'rxjs/operators';
import { HostWindowService } from '../../shared/host-window.service';
import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator';
import { MenuID } from '../../shared/menu/menu-id.model';
/**
@@ -16,7 +15,6 @@ import { MenuID } from '../../shared/menu/menu-id.model';
styleUrls: ['./expandable-navbar-section.component.scss'],
animations: [slide]
})
@rendersSectionForMenu(MenuID.PUBLIC, true)
export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit {
/**
* This section resides in the Public Navbar

View File

@@ -8,8 +8,7 @@ import { MenuID } from '../../shared/menu/menu-id.model';
* Themed wrapper for ExpandableNavbarSectionComponent
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-themed-expandable-navbar-section]',
selector: 'ds-themed-expandable-navbar-section',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
})

View File

@@ -8,8 +8,7 @@ import { MenuID } from '../../shared/menu/menu-id.model';
* Represents a non-expandable section in the navbar
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-navbar-section]',
selector: 'ds-navbar-section',
templateUrl: './navbar-section.component.html',
styleUrls: ['./navbar-section.component.scss']
})

View File

@@ -8,9 +8,9 @@
<li *ngIf="(isXsOrSm$ | async) && (isAuthenticated$ | async)">
<ds-themed-user-menu [inExpandableNavbar]="true"></ds-themed-user-menu>
</li>
<ng-container *ngFor="let section of (sections | async)">
<li *ngFor="let section of (sections | async)">
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
</ng-container>
</li>
</ul>
</div>
</div>

View File

@@ -147,7 +147,7 @@ describe('ProcessDetailComponent', () => {
providers: [
{
provide: ActivatedRoute,
useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) }
useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), snapshot: { params: { id: 1 } } },
},
{ provide: ProcessDataService, useValue: processService },
{ provide: BitstreamDataService, useValue: bitstreamDataService },
@@ -310,10 +310,11 @@ describe('ProcessDetailComponent', () => {
});
it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => {
spyOn(component, 'refresh');
spyOn(component, 'stopRefreshTimer');
spyOn(component, 'refresh').and.callThrough();
spyOn(component, 'stopRefreshTimer').and.callThrough();
process.processStatus = ProcessStatus.COMPLETED;
// start off with a running process in order for the refresh counter starts counting up
process.processStatus = ProcessStatus.RUNNING;
// set findbyId to return a completed process
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
@@ -336,6 +337,10 @@ describe('ProcessDetailComponent', () => {
tick(1001); // 1 second + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(0); // 1 - 1
// set the process to completed right before the counter checks the process
process.processStatus = ProcessStatus.COMPLETED;
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
tick(1000); // 1 second
expect(component.refresh).toHaveBeenCalledTimes(1);

View File

@@ -17,7 +17,7 @@ import {
getFirstSucceededRemoteDataPayload
} from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { AlertType } from '../../shared/alert/aletr-type';
import { AlertType } from '../../shared/alert/alert-type';
import { hasValue } from '../../shared/empty.util';
import { ProcessStatus } from '../processes/process-status.model';
import { Process } from '../processes/process.model';

View File

@@ -161,7 +161,7 @@ export class ProfilePageComponent implements OnInit {
} else {
this.notificationsService.error(
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'),
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.change-failed')
this.getPasswordErrorMessage(response)
);
}
});
@@ -199,4 +199,18 @@ export class ProfilePageComponent implements OnInit {
return this.isResearcherProfileEnabled$.asObservable();
}
/**
* Returns an error message from a password validation request with a specific reason or
* a default message without specific reason.
* @param response from the validation password patch request.
*/
getPasswordErrorMessage(response) {
if (response.hasFailed && isNotEmpty(response.errorMessage)) {
// Response has a specific error message. Show this message in the error notification.
return this.translate.instant(response.errorMessage);
}
// Show default error message notification.
return this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.change-failed');
}
}

View File

@@ -13,7 +13,7 @@ import {isNotEmpty} from '../shared/empty.util';
import {BehaviorSubject, combineLatest, Observable, of, switchMap} from 'rxjs';
import {map, startWith, take} from 'rxjs/operators';
import {CAPTCHA_NAME, GoogleRecaptchaService} from '../core/google-recaptcha/google-recaptcha.service';
import {AlertType} from '../shared/alert/aletr-type';
import {AlertType} from '../shared/alert/alert-type';
import {KlaroService} from '../shared/cookies/klaro.service';
import {CookieService} from '../core/services/cookie.service';
import { Subscription } from 'rxjs';

View File

@@ -13,7 +13,7 @@
<ng-content></ng-content>
<div class="d-flex flex-row-reverse">
<button (click)="submit()"
[disabled]="!message || message.length === 0 || !subject || subject.length === 0"
[disabled]="!subject || subject.length === 0"
class="btn btn-primary"
title="{{'grant-deny-request-copy.email.send' | translate }}">
<i class="fas fa-envelope"></i> {{'grant-deny-request-copy.email.send' | translate }}

View File

@@ -1,9 +1,12 @@
<div id="search-navbar-container" [title]="'nav.search' | translate" (dsClickOutside)="collapse()">
<div class="d-inline-block position-relative">
<form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on">
<form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on" class="d-flex">
<input #searchInput [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query"
formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}"
class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-right p-1" [attr.data-test]="'header-search-box' | dsBrowserOnly">
class="bg-transparent position-absolute form-control dropdown-menu-right pl-1 pr-4"
[class.display]="searchExpanded ? 'inline-block' : 'none'"
[tabIndex]="searchExpanded ? 0 : -1"
[attr.data-test]="'header-search-box' | dsBrowserOnly">
<button class="submit-icon btn btn-link btn-link-inline" [attr.aria-label]="'nav.search.button' | translate" type="button" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()" [attr.data-test]="'header-search-icon' | dsBrowserOnly">
<em class="fas fa-search fa-lg fa-fw"></em>
</button>

View File

@@ -12,6 +12,7 @@ input[type="text"] {
cursor: pointer;
position: sticky;
top: 0;
border: 0 !important;
color: var(--ds-header-icon-color);
&:hover, &:focus {

View File

@@ -15,7 +15,7 @@ import {
import { BulkAccessConfigDataService } from '../../core/config/bulk-access-config-data.service';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { BulkAccessConditionOptions } from '../../core/config/models/bulk-access-condition-options.model';
import { AlertType } from '../alert/aletr-type';
import { AlertType } from '../alert/alert-type';
import {
createAccessControlInitialFormState
} from './access-control-form-container-intial-state';

View File

@@ -8,7 +8,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { AlertComponent } from './alert.component';
import { createTestComponent } from '../testing/utils.test';
import { AlertType } from './aletr-type';
import { AlertType } from './alert-type';
describe('AlertComponent test suite', () => {

View File

@@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
import { trigger } from '@angular/animations';
import { AlertType } from './aletr-type';
import { AlertType } from './alert-type';
import { fadeOutLeave, fadeOutState } from '../animations/fade';
/**

View File

@@ -55,7 +55,7 @@ export const slideSidebarPadding = trigger('slideSidebarPadding', [
export const expandSearchInput = trigger('toggleAnimation', [
state('collapsed', style({
width: '30px',
width: '0',
opacity: '0'
})),
state('expanded', style({

View File

@@ -2,7 +2,7 @@
<li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"
(click)="$event.stopPropagation();">
<div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);" class="dropdownLogin px-1" [attr.aria-label]="'nav.login' |translate"
<a href="javascript:void(0);" class="dropdownLogin px-0.5" [attr.aria-label]="'nav.login' |translate"
(click)="$event.preventDefault()" [attr.data-test]="'login-menu' | dsBrowserOnly"
ngbDropdownToggle>{{ 'nav.login' | translate }}</a>
<div class="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu
@@ -13,7 +13,7 @@
</div>
</li>
<li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a routerLink="/login" routerLinkActive="active" class="loginLink px-1">
<a routerLink="/login" routerLinkActive="active" class="loginLink px-0.5">
{{ 'nav.login' | translate }}<span class="sr-only">(current)</span>
</a>
</li>

View File

@@ -42,7 +42,7 @@
[formModel]="formModel"
[displayCancel]="false"
(submitForm)="onSubmit()">
<button before (click)="back.emit()" class="btn btn-outline-secondary">
<button before (click)="back.emit()" class="btn btn-outline-secondary" type="button">
<i class="fas fa-arrow-left"></i> {{ type.value + '.edit.return' | translate }}
</button>
</ds-form>

View File

@@ -22,7 +22,7 @@ export const GOOGLE_ANALYTICS_KLARO_KEY = 'google-analytics';
export const klaroConfiguration: any = {
storageName: ANONYMOUS_STORAGE_NAME_KLARO,
privacyPolicy: '/info/privacy',
privacyPolicy: './info/privacy',
/*
Setting 'hideLearnMore' to 'true' will hide the "learn more / customize" link in

View File

@@ -1,6 +1,6 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { MenuServiceStub } from '../testing/menu-service.stub';
import { of as observableOf } from 'rxjs';
import { combineLatest, map, of as observableOf } from 'rxjs';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
@@ -16,10 +16,13 @@ import { Item } from '../../core/shared/item.model';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { MenuID } from '../menu/menu-id.model';
import { MenuItemType } from '../menu/menu-item-type.model';
import { TextMenuItemModel } from '../menu/menu-item/models/text.model';
import { LinkMenuItemModel } from '../menu/menu-item/models/link.model';
import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service';
import { NotificationsService } from '../notifications/notifications.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Community } from '../../core/shared/community.model';
import { Collection } from '../../core/shared/collection.model';
import flatten from 'lodash/flatten';
describe('DSOEditMenuResolver', () => {
@@ -37,25 +40,44 @@ describe('DSOEditMenuResolver', () => {
let notificationsService;
let translate;
const route = {
data: {
menu: {
'statistics': [{
id: 'statistics-dummy-1',
active: false,
visible: true,
model: null
}]
}
},
params: {id: 'test-uuid'},
const dsoRoute = (dso: DSpaceObject) => {
return {
data: {
menu: {
'statistics': [{
id: 'statistics-dummy-1',
active: false,
visible: true,
model: null
}]
}
},
params: {id: dso.uuid},
};
};
const state = {
url: 'test-url'
};
const testObject = Object.assign(new Item(), {uuid: 'test-uuid', type: 'item', _links: {self: {href: 'self-link'}}});
const testCommunity: Community = Object.assign(new Community(), {
uuid: 'test-community-uuid',
type: 'community',
_links: {self: {href: 'self-link'}},
});
const testCollection: Collection = Object.assign(new Collection(), {
uuid: 'test-collection-uuid',
type: 'collection',
_links: {self: {href: 'self-link'}},
});
const testItem: Item = Object.assign(new Item(), {
uuid: 'test-item-uuid',
type: 'item',
_links: {self: {href: 'self-link'}},
});
let testObject: DSpaceObject;
let route;
const dummySections1 = [{
id: 'dummy-1',
@@ -90,6 +112,10 @@ describe('DSOEditMenuResolver', () => {
}];
beforeEach(waitForAsync(() => {
// test with Items unless specified otherwise
testObject = testItem;
route = dsoRoute(testItem);
menuService = new MenuServiceStub();
spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE));
@@ -154,16 +180,17 @@ describe('DSOEditMenuResolver', () => {
{
...route.data.menu,
[MenuID.DSO_EDIT]: [
...dummySections1.map((menu) => Object.assign(menu, {id: menu.id + '-test-uuid'})),
...dummySections2.map((menu) => Object.assign(menu, {id: menu.id + '-test-uuid'}))
...dummySections1.map((menu) => Object.assign(menu, {id: menu.id + '-test-item-uuid'})),
...dummySections2.map((menu) => Object.assign(menu, {id: menu.id + '-test-item-uuid'}))
]
}
);
expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-uuid', true, false);
expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-item-uuid', true, false);
expect(resolver.getDsoMenus).toHaveBeenCalled();
done();
});
});
it('should create all menus when a dso is found based on the route scope query param when no id param is present', (done) => {
spyOn(resolver, 'getDsoMenus').and.returnValue(
[observableOf(dummySections1), observableOf(dummySections2)]
@@ -198,6 +225,7 @@ describe('DSOEditMenuResolver', () => {
done();
});
});
it('should return the statistics menu when no dso is found', (done) => {
(dSpaceObjectDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
@@ -211,49 +239,165 @@ describe('DSOEditMenuResolver', () => {
});
});
});
describe('getDsoMenus', () => {
it('should return as first part the item version, orcid and claim list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
result[0].subscribe((menuList) => {
expect(menuList.length).toEqual(3);
expect(menuList[0].id).toEqual('orcid-dso');
expect(menuList[0].active).toEqual(false);
// Visible should be false due to the item not being of type person
expect(menuList[0].visible).toEqual(false);
expect(menuList[0].model.type).toEqual(MenuItemType.LINK);
expect(menuList[1].id).toEqual('version-dso');
expect(menuList[1].active).toEqual(false);
expect(menuList[1].visible).toEqual(true);
expect(menuList[1].model.type).toEqual(MenuItemType.ONCLICK);
expect((menuList[1].model as TextMenuItemModel).text).toEqual('message');
expect(menuList[1].model.disabled).toEqual(false);
expect(menuList[1].icon).toEqual('code-branch');
expect(menuList[2].id).toEqual('claim-dso');
expect(menuList[2].active).toEqual(false);
// Visible should be false due to the item not being of type person
expect(menuList[2].visible).toEqual(false);
expect(menuList[2].model.type).toEqual(MenuItemType.ONCLICK);
expect((menuList[2].model as TextMenuItemModel).text).toEqual('item.page.claim.button');
done();
describe('for Communities', () => {
beforeEach(() => {
testObject = testCommunity;
dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testCommunity));
route = dsoRoute(testCommunity);
});
it('should not return Item-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
expect(orcidEntry).toBeFalsy();
const versionEntry = menu.find(entry => entry.id === 'version-dso');
expect(versionEntry).toBeFalsy();
const claimEntry = menu.find(entry => entry.id === 'claim-dso');
expect(claimEntry).toBeFalsy();
done();
});
});
it('should return Community/Collection-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeTruthy();
expect(subscribeEntry.active).toBeFalse();
expect(subscribeEntry.visible).toBeTrue();
expect(subscribeEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should return as third part the common list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
expect(editEntry).toBeTruthy();
expect(editEntry.active).toBeFalse();
expect(editEntry.visible).toBeTrue();
expect(editEntry.model.type).toEqual(MenuItemType.LINK);
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/communities/test-community-uuid/edit/metadata'
);
done();
});
});
});
it('should return as second part the common list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
result[1].subscribe((menuList) => {
expect(menuList.length).toEqual(1);
expect(menuList[0].id).toEqual('edit-dso');
expect(menuList[0].active).toEqual(false);
expect(menuList[0].visible).toEqual(true);
expect(menuList[0].model.type).toEqual(MenuItemType.LINK);
expect((menuList[0].model as LinkMenuItemModel).text).toEqual('item.page.edit');
expect((menuList[0].model as LinkMenuItemModel).link).toEqual('/items/test-uuid/edit/metadata');
expect(menuList[0].icon).toEqual('pencil-alt');
done();
describe('for Collections', () => {
beforeEach(() => {
testObject = testCollection;
dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testCollection));
route = dsoRoute(testCollection);
});
it('should not return Item-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
expect(orcidEntry).toBeFalsy();
const versionEntry = menu.find(entry => entry.id === 'version-dso');
expect(versionEntry).toBeFalsy();
const claimEntry = menu.find(entry => entry.id === 'claim-dso');
expect(claimEntry).toBeFalsy();
done();
});
});
it('should return Community/Collection-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeTruthy();
expect(subscribeEntry.active).toBeFalse();
expect(subscribeEntry.visible).toBeTrue();
expect(subscribeEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should return as third part the common list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
expect(editEntry).toBeTruthy();
expect(editEntry.active).toBeFalse();
expect(editEntry.visible).toBeTrue();
expect(editEntry.model.type).toEqual(MenuItemType.LINK);
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/collections/test-collection-uuid/edit/metadata'
);
done();
});
});
});
describe('for Items', () => {
beforeEach(() => {
testObject = testItem;
dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testItem));
route = dsoRoute(testItem);
});
it('should return Item-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
expect(orcidEntry).toBeTruthy();
expect(orcidEntry.active).toBeFalse();
expect(orcidEntry.visible).toBeFalse();
expect(orcidEntry.model.type).toEqual(MenuItemType.LINK);
const versionEntry = menu.find(entry => entry.id === 'version-dso');
expect(versionEntry).toBeTruthy();
expect(versionEntry.active).toBeFalse();
expect(versionEntry.visible).toBeTrue();
expect(versionEntry.model.type).toEqual(MenuItemType.ONCLICK);
expect(versionEntry.model.disabled).toBeFalse();
const claimEntry = menu.find(entry => entry.id === 'claim-dso');
expect(claimEntry).toBeTruthy();
expect(claimEntry.active).toBeFalse();
expect(claimEntry.visible).toBeFalse();
expect(claimEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should not return Community/Collection-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeFalsy();
done();
});
});
it('should return as third part the common list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
expect(editEntry).toBeTruthy();
expect(editEntry.active).toBeFalse();
expect(editEntry.visible).toBeTrue();
expect(editEntry.model.type).toEqual(MenuItemType.LINK);
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/items/test-item-uuid/edit/metadata'
);
done();
});
});
});
});
});

View File

@@ -21,6 +21,9 @@ import { getDSORoute } from '../../app-routing-paths';
import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service';
import { NotificationsService } from '../notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { SubscriptionModalComponent } from '../subscriptions/subscription-modal/subscription-modal.component';
import { Community } from '../../core/shared/community.model';
import { Collection } from '../../core/shared/collection.model';
/**
* Creates the menus for the dspace object pages
@@ -84,6 +87,7 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection
getDsoMenus(dso, route, state): Observable<MenuSection[]>[] {
return [
this.getItemMenu(dso),
this.getComColMenu(dso),
this.getCommonMenu(dso, state)
];
}
@@ -178,6 +182,39 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection
}
}
/**
* Get Community/Collection-specific menus
*/
protected getComColMenu(dso): Observable<MenuSection[]> {
if (dso instanceof Community || dso instanceof Collection) {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanSubscribe, dso.self),
]).pipe(
map(([canSubscribe]) => {
return [
{
id: 'subscribe',
active: false,
visible: canSubscribe,
model: {
type: MenuItemType.ONCLICK,
text: 'subscriptions.tooltip',
function: () => {
const modalRef = this.modalService.open(SubscriptionModalComponent);
modalRef.componentInstance.dso = dso;
}
} as OnClickMenuItemModel,
icon: 'bell',
index: 4
},
];
})
);
} else {
return observableOf([]);
}
}
/**
* Claim a researcher by creating a profile
* Shows notifications and/or hides the menu section on success/error

View File

@@ -13,7 +13,6 @@ import { hasValue } from '../../../empty.util';
* Represents an expandable section in the dso edit menus
*/
@Component({
/* tslint:disable:component-selector */
selector: 'ds-dso-edit-menu-expandable-section',
templateUrl: './dso-edit-menu-expandable-section.component.html',
styleUrls: ['./dso-edit-menu-expandable-section.component.scss'],

View File

@@ -10,7 +10,6 @@ import { MenuSection } from '../../../menu/menu-section.model';
* Represents a non-expandable section in the dso edit menus
*/
@Component({
/* tslint:disable:component-selector */
selector: 'ds-dso-edit-menu-section',
templateUrl: './dso-edit-menu-section.component.html',
styleUrls: ['./dso-edit-menu-section.component.scss']

View File

@@ -1,8 +0,0 @@
<button *ngIf="isAuthorized$ | async" data-test="subscription-button"
(click)="openSubscriptionModal()"
[ngbTooltip]="'subscriptions.tooltip' | translate"
[title]="'subscriptions.tooltip' | translate"
[attr.aria-label]="'subscriptions.tooltip' | translate"
class="subscription-button btn btn-dark btn-sm">
<i class="fas fa-bell fa-fw"></i>
</button>

View File

@@ -1,83 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DsoPageSubscriptionButtonComponent } from './dso-page-subscription-button.component';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { of as observableOf } from 'rxjs';
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ITEM } from '../../../core/shared/item.resource-type';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
describe('DsoPageSubscriptionButtonComponent', () => {
let component: DsoPageSubscriptionButtonComponent;
let fixture: ComponentFixture<DsoPageSubscriptionButtonComponent>;
let de: DebugElement;
const authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: jasmine.createSpy('isAuthorized') // observableOf(true)
});
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
uuid: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
type: ITEM,
_links: {
self: {
href: 'https://localhost:8000/items/fake-id'
}
}
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NgbModalModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})
],
declarations: [ DsoPageSubscriptionButtonComponent ],
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DsoPageSubscriptionButtonComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
component.dso = mockItem;
});
describe('when is authorized', () => {
beforeEach(() => {
authorizationService.isAuthorized.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('should display subscription button', () => {
expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeTruthy();
});
});
describe('when is not authorized', () => {
beforeEach(() => {
authorizationService.isAuthorized.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('should not display subscription button', () => {
expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeNull();
});
});
});

View File

@@ -1,57 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
@Component({
selector: 'ds-dso-page-subscription-button',
templateUrl: './dso-page-subscription-button.component.html',
styleUrls: ['./dso-page-subscription-button.component.scss']
})
/**
* Display a button that opens the modal to manage subscriptions
*/
export class DsoPageSubscriptionButtonComponent implements OnInit {
/**
* Whether the current user is authorized to edit the DSpaceObject
*/
isAuthorized$: Observable<boolean> = of(false);
/**
* Reference to NgbModal
*/
public modalRef: NgbModalRef;
/**
* DSpaceObject that is being viewed
*/
@Input() dso: DSpaceObject;
constructor(
protected authorizationService: AuthorizationDataService,
private modalService: NgbModal,
) {
}
/**
* check if the current DSpaceObject can be subscribed by the user
*/
ngOnInit(): void {
this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanSubscribe, this.dso.self);
}
/**
* Open the modal to subscribe to the related DSpaceObject
*/
public openSubscriptionModal() {
this.modalRef = this.modalService.open(SubscriptionModalComponent);
this.modalRef.componentInstance.dso = this.dso;
}
}

View File

@@ -3,7 +3,7 @@ import { Component, Input } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { AlertType } from '../alert/aletr-type';
import { AlertType } from '../alert/alert-type';
@Component({
selector: 'ds-error',

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