Merge remote-tracking branch 'lyrasis/dspace-7_x' into task/dspace-7_x/CST-14904

# Conflicts:
#	src/app/shared/shared.module.ts
This commit is contained in:
Andrea Barbasso
2024-10-01 09:27:53 +02:00
24 changed files with 481 additions and 25 deletions

View File

@@ -38,7 +38,7 @@ jobs:
strategy:
# Create a matrix of Node versions to test against (in parallel)
matrix:
node-version: [16.x, 18.x]
node-version: [18.x, 20.x]
# Do NOT exit immediately if one matrix job fails
fail-fast: false
# These are the actual CI steps to perform per job

View File

@@ -396,3 +396,17 @@ vocabularies:
comcolSelectionSort:
sortField: 'dc.title'
sortDirection: 'ASC'
# Live Region configuration
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
# Live regions are perceivable regions of a web page that are typically updated as a
# result of an external event when user focus may be elsewhere.
#
# The DSpace live region is a component present at the bottom of all pages that is invisible by default, but is useful
# for screen readers. Any message pushed to the live region will be announced by the screen reader. These messages
# usually contain information about changes on the page that might not be in focus.
liveRegion:
# The duration after which messages disappear from the live region in milliseconds
messageTimeOutDurationMs: 30000
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
isVisible: false

View File

@@ -100,7 +100,7 @@
"filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5",
"http-terminator": "^3.2.0",
"isbot": "^3.6.10",
"isbot": "^5.1.17",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
"json5": "^2.2.3",

View File

@@ -28,7 +28,7 @@ import * as expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */
import axios from 'axios';
import LRU from 'lru-cache';
import isbot from 'isbot';
import { isbot } from 'isbot';
import { createCertificate } from 'pem';
import { createServer } from 'https';
import { json } from 'body-parser';

View File

@@ -1,7 +1,9 @@
<ng-container *ngIf="relationTypes.length > 1">
<ul ngbNav #tabs="ngbNav" [destroyOnHide]="true" [activeId]="activeTab$ | async" (navChange)="onTabChange($event)" class="nav-tabs">
<li *ngFor="let relationType of relationTypes" [ngbNavItem]="relationType.filter" rel="presentation">
<a ngbNavLink>{{'item.page.relationships.' + relationType.label | translate}}</a>
<ul ngbNav #tabs="ngbNav" [destroyOnHide]="true" [activeId]="activeTab$ | async" (navChange)="onTabChange($event)" class="nav-tabs" role="tablist">
<li *ngFor="let relationType of relationTypes" [ngbNavItem]="relationType.filter" role="presentation">
<a ngbNavLink role="tab">
{{'item.page.relationships.' + relationType.label | translate}}
</a>
<ng-template ngbNavContent>
<div class="mt-4">
<ds-related-entities-search [item]="item"

View File

@@ -15,8 +15,7 @@
[attr.aria-expanded]="isActive"
[attr.aria-controls]="expandableNavbarSectionId(section.id)"
class="d-flex flex-row flex-nowrap align-items-center gapx-1 ds-menu-toggler-wrapper"
[class.disabled]="section.model?.disabled"
id="browseDropdown">
[class.disabled]="section.model?.disabled">
<span class="flex-fill">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>

View File

@@ -10,7 +10,7 @@
/** Mobile menu styling **/
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
.navbar {
width: 100%;
width: 100vw;
background-color: var(--bs-white);
position: absolute;
overflow: hidden;

View File

@@ -31,3 +31,5 @@
<div class="ds-full-screen-loader" *ngIf="shouldShowFullscreenLoader">
<ds-themed-loading [showMessage]="false"></ds-themed-loading>
</div>
<ds-live-region></ds-live-region>

View File

@@ -3,6 +3,7 @@
<div class="col-12">
<div class="input-group">
<input type="text" class="form-control" [(ngModel)]="searchText" (keyup.enter)="search()"
[attr.aria-label]="'vocabulary-treeview.search.form.search-placeholder' | translate"
[placeholder]="'vocabulary-treeview.search.form.search-placeholder' | translate">
<div class="input-group-append" id="button-addon4">
<button class="btn btn-outline-primary" type="button" (click)="search()" [disabled]="!isSearchEnabled()">

View File

@@ -0,0 +1,3 @@
<div class="live-region" [ngClass]="{'visually-hidden': !isVisible }" aria-live="assertive" role="log" aria-relevant="additions" aria-atomic="true">
<div class="live-region-message" *ngFor="let message of (messages$ | async)">{{ message }}</div>
</div>

View File

@@ -0,0 +1,13 @@
.live-region {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-left: 60px;
height: 90px;
line-height: 18px;
color: var(--bs-white);
background-color: var(--bs-dark);
opacity: 0.94;
z-index: var(--ds-live-region-z-index);
}

View File

@@ -0,0 +1,57 @@
import { LiveRegionComponent } from './live-region.component';
import { ComponentFixture, waitForAsync, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { LiveRegionService } from './live-region.service';
import { of } from 'rxjs';
import { By } from '@angular/platform-browser';
describe('liveRegionComponent', () => {
let fixture: ComponentFixture<LiveRegionComponent>;
let liveRegionService: LiveRegionService;
beforeEach(waitForAsync(() => {
liveRegionService = jasmine.createSpyObj('liveRegionService', {
getMessages$: of(['message1', 'message2']),
getLiveRegionVisibility: false,
setLiveRegionVisibility: undefined,
});
void TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
],
declarations: [LiveRegionComponent],
providers: [
{ provide: LiveRegionService, useValue: liveRegionService },
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LiveRegionComponent);
fixture.detectChanges();
});
it('should contain the current live region messages', () => {
const messages = fixture.debugElement.queryAll(By.css('.live-region-message'));
expect(messages.length).toEqual(2);
expect(messages[0].nativeElement.textContent).toEqual('message1');
expect(messages[1].nativeElement.textContent).toEqual('message2');
});
it('should respect the live region visibility', () => {
const liveRegion = fixture.debugElement.query(By.css('.live-region'));
expect(liveRegion).toBeDefined();
const liveRegionHidden = fixture.debugElement.query(By.css('.visually-hidden'));
expect(liveRegionHidden).toBeDefined();
liveRegionService.getLiveRegionVisibility = jasmine.createSpy('getLiveRegionVisibility').and.returnValue(true);
fixture = TestBed.createComponent(LiveRegionComponent);
fixture.detectChanges();
const liveRegionVisible = fixture.debugElement.query(By.css('.visually-hidden'));
expect(liveRegionVisible).toBeNull();
});
});

View File

@@ -0,0 +1,31 @@
import { Component, OnInit } from '@angular/core';
import { LiveRegionService } from './live-region.service';
import { Observable } from 'rxjs';
/**
* The Live Region Component is an accessibility tool for screenreaders. When a change occurs on a page when the changed
* section is not in focus, a message should be displayed by this component so it can be announced by a screen reader.
*
* This component should not be used directly. Use the {@link LiveRegionService} to add messages.
*/
@Component({
selector: `ds-live-region`,
templateUrl: './live-region.component.html',
styleUrls: ['./live-region.component.scss'],
})
export class LiveRegionComponent implements OnInit {
protected isVisible: boolean;
protected messages$: Observable<string[]>;
constructor(
protected liveRegionService: LiveRegionService,
) {
}
ngOnInit() {
this.isVisible = this.liveRegionService.getLiveRegionVisibility();
this.messages$ = this.liveRegionService.getMessages$();
}
}

View File

@@ -0,0 +1,9 @@
import { Config } from '../../../config/config.interface';
/**
* Configuration interface used by the LiveRegionService
*/
export class LiveRegionConfig implements Config {
messageTimeOutDurationMs: number;
isVisible: boolean;
}

View File

@@ -0,0 +1,170 @@
import { LiveRegionService } from './live-region.service';
import { fakeAsync, tick, flush } from '@angular/core/testing';
import { UUIDService } from '../../core/shared/uuid.service';
describe('liveRegionService', () => {
let service: LiveRegionService;
beforeEach(() => {
service = new LiveRegionService(
new UUIDService(),
);
});
describe('addMessage', () => {
it('should correctly add messages', () => {
expect(service.getMessages().length).toEqual(0);
service.addMessage('Message One');
expect(service.getMessages().length).toEqual(1);
expect(service.getMessages()[0]).toEqual('Message One');
service.addMessage('Message Two');
expect(service.getMessages().length).toEqual(2);
expect(service.getMessages()[1]).toEqual('Message Two');
});
});
describe('clearMessages', () => {
it('should clear the messages', () => {
expect(service.getMessages().length).toEqual(0);
service.addMessage('Message One');
service.addMessage('Message Two');
expect(service.getMessages().length).toEqual(2);
service.clear();
expect(service.getMessages().length).toEqual(0);
});
});
describe('messages$', () => {
it('should emit when a message is added and when a message is removed after the timeOut', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
service.addMessage('message');
tick();
expect(results.length).toEqual(2);
expect(results[1]).toEqual(['message']);
tick(service.getMessageTimeOutMs());
expect(results.length).toEqual(3);
expect(results[2]).toEqual([]);
}));
it('should only emit once when the messages are cleared', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
service.addMessage('Message One');
service.addMessage('Message Two');
tick();
expect(results.length).toEqual(3);
expect(results[2]).toEqual(['Message One', 'Message Two']);
service.clear();
flush();
expect(results.length).toEqual(4);
expect(results[3]).toEqual([]);
}));
it('should not pop messages added after clearing within timeOut period', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
service.addMessage('Message One');
tick(10000);
service.clear();
tick(15000);
service.addMessage('Message Two');
// Message Two should not be cleared after 5 more seconds
tick(5000);
expect(results.length).toEqual(4);
expect(results[3]).toEqual(['Message Two']);
// But should be cleared 30 seconds after it was added
tick(25000);
expect(results.length).toEqual(5);
expect(results[4]).toEqual([]);
}));
it('should respect configured timeOut', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
const timeOutMs = 500;
service.setMessageTimeOutMs(timeOutMs);
service.addMessage('Message One');
tick(timeOutMs - 1);
expect(results.length).toEqual(2);
expect(results[1]).toEqual(['Message One']);
tick(1);
expect(results.length).toEqual(3);
expect(results[2]).toEqual([]);
const timeOutMsTwo = 50000;
service.setMessageTimeOutMs(timeOutMsTwo);
service.addMessage('Message Two');
tick(timeOutMsTwo - 1);
expect(results.length).toEqual(4);
expect(results[3]).toEqual(['Message Two']);
tick(1);
expect(results.length).toEqual(5);
expect(results[4]).toEqual([]);
}));
});
describe('liveRegionVisibility', () => {
it('should be false by default', () => {
expect(service.getLiveRegionVisibility()).toBeFalse();
});
it('should correctly update', () => {
service.setLiveRegionVisibility(true);
expect(service.getLiveRegionVisibility()).toBeTrue();
service.setLiveRegionVisibility(false);
expect(service.getLiveRegionVisibility()).toBeFalse();
});
});
});

View File

@@ -0,0 +1,132 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UUIDService } from '../../core/shared/uuid.service';
/**
* The LiveRegionService is responsible for handling the messages that are shown by the {@link LiveRegionComponent}.
* Use this service to add or remove messages to the Live Region.
*/
@Injectable({
providedIn: 'root',
})
export class LiveRegionService {
constructor(
protected uuidService: UUIDService,
) {
}
/**
* The duration after which the messages disappear in milliseconds
* @protected
*/
protected messageTimeOutDurationMs: number = environment.liveRegion.messageTimeOutDurationMs;
/**
* Array containing the messages that should be shown in the live region,
* together with a uuid, so they can be uniquely identified
* @protected
*/
protected messages: { message: string, uuid: string }[] = [];
/**
* BehaviorSubject emitting the array with messages every time the array updates
* @protected
*/
protected messages$: BehaviorSubject<string[]> = new BehaviorSubject([]);
/**
* Whether the live region should be visible
* @protected
*/
protected liveRegionIsVisible: boolean = environment.liveRegion.isVisible;
/**
* Returns a copy of the array with the current live region messages
*/
getMessages(): string[] {
return this.messages.map(messageObj => messageObj.message);
}
/**
* Returns the BehaviorSubject emitting the array with messages every time the array updates
*/
getMessages$(): BehaviorSubject<string[]> {
return this.messages$;
}
/**
* Adds a message to the live-region messages array
* @param message
* @return The uuid of the message
*/
addMessage(message: string): string {
const uuid = this.uuidService.generate();
this.messages.push({ message, uuid });
setTimeout(() => this.clearMessageByUUID(uuid), this.messageTimeOutDurationMs);
this.emitCurrentMessages();
return uuid;
}
/**
* Clears the live-region messages array
*/
clear() {
this.messages = [];
this.emitCurrentMessages();
}
/**
* Removes the message with the given UUID from the messages array
* @param uuid The uuid of the message to clear
*/
clearMessageByUUID(uuid: string) {
const index = this.messages.findIndex(messageObj => messageObj.uuid === uuid);
if (index !== -1) {
this.messages.splice(index, 1);
this.emitCurrentMessages();
}
}
/**
* Makes the messages$ BehaviorSubject emit the current messages array
* @protected
*/
protected emitCurrentMessages() {
this.messages$.next(this.getMessages());
}
/**
* Returns a boolean specifying whether the live region should be visible.
* Returns 'true' if the region should be visible and false otherwise.
*/
getLiveRegionVisibility(): boolean {
return this.liveRegionIsVisible;
}
/**
* Sets the visibility of the live region.
* Setting this to true will make the live region visible which is useful for debugging purposes.
* @param isVisible
*/
setLiveRegionVisibility(isVisible: boolean) {
this.liveRegionIsVisible = isVisible;
}
/**
* Gets the current message timeOut duration in milliseconds
*/
getMessageTimeOutMs(): number {
return this.messageTimeOutDurationMs;
}
/**
* Sets the message timeOut duration
* @param timeOutMs the message timeOut duration in milliseconds
*/
setMessageTimeOutMs(timeOutMs: number) {
this.messageTimeOutDurationMs = timeOutMs;
}
}

View File

@@ -285,6 +285,7 @@ import { NgxPaginationModule } from 'ngx-pagination';
import { ThemedLangSwitchComponent } from './lang-switch/themed-lang-switch.component';
import {ThemedUserMenuComponent} from './auth-nav-menu/user-menu/themed-user-menu.component';
import { OrcidBadgeAndTooltipComponent } from './orcid-badge-and-tooltip/orcid-badge-and-tooltip.component';
import { LiveRegionComponent } from './live-region/live-region.component';
const MODULES = [
CommonModule,
@@ -469,7 +470,8 @@ const ENTRY_COMPONENTS = [
AdvancedClaimedTaskActionRatingComponent,
EpersonGroupListComponent,
EpersonSearchBoxComponent,
GroupSearchBoxComponent
GroupSearchBoxComponent,
LiveRegionComponent,
];
const PROVIDERS = [

View File

@@ -9,8 +9,6 @@
// "401.unauthorized": "unauthorized",
"401.unauthorized": "unautorisiert",
// "403.help": "You don't have permission to access this page. You can use the button below to get back to the home page.",
"403.help": "Sie sind nicht berechtigt, auf diese Seite zuzugreifen. Über den Button unten auf der Seite gelangen Sie zurück zur Startseite.",
@@ -20,8 +18,6 @@
// "403.forbidden": "forbidden",
"403.forbidden": "verboten",
// "404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ",
"404.help": "Die Seite konnte nicht gefunden werden. Eventuell wurde sie verschoben oder gelöscht. Über den Button unten auf der Seite gelangen Sie zurück zur Startseite.",
@@ -31,6 +27,16 @@
// "404.page-not-found": "page not found",
"404.page-not-found": "Seite nicht gefunden",
// "500.page-internal-server-error": "Service unavailable",
"500.page-internal-server-error": "Dienst nicht verfügbar",
// "500.help": "The server is temporarily unable to service your request due to maintenance downtime or capacity problems. Please try again later.",
"500.help": "Der Dienst steht momentan nicht zur Verfügung. Bitte versuchen Sie es später noch einmal.",
// "500.link.home-page": "Take me to the home page",
"500.link.home-page": "Zur Startseite",
// "admin.access-control.epeople.breadcrumbs": "EPeople",
"admin.access-control.epeople.breadcrumbs": "Personen suchen",
@@ -5143,10 +5149,10 @@
"submission.sections.general.deposit_error_notice": "Beim Einreichen des Items ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
// "submission.sections.general.deposit_success_notice": "Submission deposited successfully.",
"submission.sections.general.deposit_success_notice": "Veröffentlichung erfolgreich eingereicht",
"submission.sections.general.deposit_success_notice": "Veröffentlichung erfolgreich eingereicht.",
// "submission.sections.general.discard_error_notice": "There was an issue when discarding the item, please try again later.",
"submission.sections.general.discard_error_notice": "Beim Verwerfen der Einreichung ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal",
"submission.sections.general.discard_error_notice": "Beim Verwerfen der Einreichung ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
// "submission.sections.general.discard_success_notice": "Submission discarded successfully.",
"submission.sections.general.discard_success_notice": "Einreichung erfolgreich verworfen.",

View File

@@ -1240,7 +1240,7 @@
"community.edit.notifications.unauthorized": "You do not have privileges to make this change",
"community.edit.notifications.error": "An error occured while editing the community",
"community.edit.notifications.error": "An error occurred while editing the community",
"community.edit.return": "Back",
@@ -1448,7 +1448,7 @@
"curation.form.submit.error.head": "Running the curation task failed",
"curation.form.submit.error.content": "An error occured when trying to start the curation task.",
"curation.form.submit.error.content": "An error occurred when trying to start the curation task.",
"curation.form.submit.error.invalid-handle": "Couldn't determine the handle for this object",
@@ -1700,7 +1700,7 @@
"forgot-email.form.error.head": "Error when trying to reset password",
"forgot-email.form.error.content": "An error occured when attempting to reset the password for the account associated with the following email address: {{ email }}",
"forgot-email.form.error.content": "An error occurred when attempting to reset the password for the account associated with the following email address: {{ email }}",
"forgot-password.title": "Forgot Password",
@@ -3518,7 +3518,7 @@
"register-page.registration.error.head": "Error when trying to register email",
"register-page.registration.error.content": "An error occured when registering the following email address: {{ email }}",
"register-page.registration.error.content": "An error occurred when registering the following email address: {{ email }}",
"register-page.registration.error.recaptcha": "Error when trying to authenticate with recaptcha",

View File

@@ -22,6 +22,7 @@ import { HomeConfig } from './homepage-config.interface';
import { MarkdownConfig } from './markdown-config.interface';
import { FilterVocabularyConfig } from './filter-vocabulary-config';
import { DiscoverySortConfig } from './discovery-sort.config';
import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
interface AppConfig extends Config {
ui: UIServerConfig;
@@ -48,6 +49,7 @@ interface AppConfig extends Config {
markdown: MarkdownConfig;
vocabularies: FilterVocabularyConfig[];
comcolSelectionSort: DiscoverySortConfig;
liveRegion: LiveRegionConfig;
}
/**

View File

@@ -22,6 +22,7 @@ import { HomeConfig } from './homepage-config.interface';
import { MarkdownConfig } from './markdown-config.interface';
import { FilterVocabularyConfig } from './filter-vocabulary-config';
import { DiscoverySortConfig } from './discovery-sort.config';
import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
export class DefaultAppConfig implements AppConfig {
production = false;
@@ -435,4 +436,10 @@ export class DefaultAppConfig implements AppConfig {
sortField:'dc.title',
sortDirection:'ASC',
};
// Live Region configuration, used by the LiveRegionService
liveRegion: LiveRegionConfig = {
messageTimeOutDurationMs: 30000,
isVisible: false,
};
}

View File

@@ -314,5 +314,10 @@ export const environment: BuildConfig = {
vocabulary: 'srsc',
enabled: true
}
]
],
liveRegion: {
messageTimeOutDurationMs: 30000,
isVisible: false,
},
};

View File

@@ -13,6 +13,7 @@
--ds-login-logo-width:72px;
--ds-submission-header-z-index: 1001;
--ds-submission-footer-z-index: 999;
--ds-live-region-z-index: 1030;
--ds-main-z-index: 1;
--ds-nav-z-index: 10;

View File

@@ -7188,10 +7188,10 @@ isbinaryfile@^4.0.8:
resolved "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz"
integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
isbot@^3.6.10:
version "3.6.10"
resolved "https://registry.yarnpkg.com/isbot/-/isbot-3.6.10.tgz#7b66334e81794f0461794debb567975cf08eaf2b"
integrity sha512-+I+2998oyP4oW9+OTQD8TS1r9P6wv10yejukj+Ksj3+UR5pUhsZN3f8W7ysq0p1qxpOVNbl5mCuv0bCaF8y5iQ==
isbot@^5.1.17:
version "5.1.17"
resolved "https://registry.yarnpkg.com/isbot/-/isbot-5.1.17.tgz#ad7da5690a61bbb19056a069975c9a73182682a0"
integrity sha512-/wch8pRKZE+aoVhRX/hYPY1C7dMCeeMyhkQLNLNlYAbGQn9bkvMB8fOUXNnk5I0m4vDYbBJ9ciVtkr9zfBJ7qA==
isexe@^2.0.0:
version "2.0.0"