diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts
index 9b123ae4470..22934394c8a 100644
--- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts
+++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts
@@ -54,6 +54,7 @@ import {
getFirstCompletedRemoteData,
getRemoteDataPayload,
} from '../../../../core/shared/operators';
+import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive';
import { ContextHelpDirective } from '../../../../shared/context-help.directive';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
@@ -113,6 +114,7 @@ export interface EPersonListActionConfig {
RouterLink,
NgClass,
NgForOf,
+ BtnDisabledDirective,
],
standalone: true,
})
diff --git a/src/app/access-control/group-registry/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html
index c2d998d9541..4150e2560c5 100644
--- a/src/app/access-control/group-registry/groups-registry.component.html
+++ b/src/app/access-control/group-registry/groups-registry.component.html
@@ -69,7 +69,7 @@ {{messagePrefix + 'search.head' | tra
{
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot(),
GroupsRegistryComponent,
+ BtnDisabledDirective,
],
providers: [GroupsRegistryComponent,
{ provide: DSONameService, useValue: new DSONameServiceMock() },
@@ -278,7 +280,8 @@ describe('GroupsRegistryComponent', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
- expect(editButtonFound.nativeElement.disabled).toBeFalse();
+ expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBeNull();
+ expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeFalse();
});
});
@@ -312,7 +315,8 @@ describe('GroupsRegistryComponent', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
- expect(editButtonFound.nativeElement.disabled).toBeFalse();
+ expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBeNull();
+ expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeFalse();
});
});
});
@@ -331,7 +335,8 @@ describe('GroupsRegistryComponent', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
- expect(editButtonFound.nativeElement.disabled).toBeTrue();
+ expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBe('true');
+ expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeTrue();
});
});
});
diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts
index dec3dc955d9..07b7317a1e2 100644
--- a/src/app/access-control/group-registry/groups-registry.component.ts
+++ b/src/app/access-control/group-registry/groups-registry.component.ts
@@ -62,6 +62,7 @@ import {
getRemoteDataPayload,
} from '../../core/shared/operators';
import { PageInfo } from '../../core/shared/page-info.model';
+import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { hasValue } from '../../shared/empty.util';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -84,6 +85,7 @@ import { followLink } from '../../shared/utils/follow-link-config.model';
NgSwitchCase,
NgbTooltipModule,
NgForOf,
+ BtnDisabledDirective,
],
standalone: true,
})
diff --git a/src/app/accessibility/accessibility-settings.config.ts b/src/app/accessibility/accessibility-settings.config.ts
new file mode 100644
index 00000000000..1852579c3d3
--- /dev/null
+++ b/src/app/accessibility/accessibility-settings.config.ts
@@ -0,0 +1,11 @@
+import { Config } from '../../config/config.interface';
+
+/**
+ * Configuration interface used by the AccessibilitySettingsService
+ */
+export class AccessibilitySettingsConfig implements Config {
+ /**
+ * The duration in days after which the accessibility settings cookie expires
+ */
+ cookieExpirationDuration: number;
+}
diff --git a/src/app/accessibility/accessibility-settings.service.spec.ts b/src/app/accessibility/accessibility-settings.service.spec.ts
new file mode 100644
index 00000000000..599ad070d25
--- /dev/null
+++ b/src/app/accessibility/accessibility-settings.service.spec.ts
@@ -0,0 +1,419 @@
+import {
+ fakeAsync,
+ flush,
+} from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { AppConfig } from '../../config/app-config.interface';
+import { AuthService } from '../core/auth/auth.service';
+import { EPersonDataService } from '../core/eperson/eperson-data.service';
+import { EPerson } from '../core/eperson/models/eperson.model';
+import { CookieService } from '../core/services/cookie.service';
+import { KlaroServiceStub } from '../shared/cookies/klaro.service.stub';
+import { CookieServiceMock } from '../shared/mocks/cookie.service.mock';
+import {
+ createFailedRemoteDataObject$,
+ createSuccessfulRemoteDataObject$,
+} from '../shared/remote-data.utils';
+import { AuthServiceStub } from '../shared/testing/auth-service.stub';
+import {
+ ACCESSIBILITY_COOKIE,
+ ACCESSIBILITY_SETTINGS_METADATA_KEY,
+ AccessibilitySettings,
+ AccessibilitySettingsFormValues,
+ AccessibilitySettingsService,
+ FullAccessibilitySettings,
+} from './accessibility-settings.service';
+
+
+describe('accessibilitySettingsService', () => {
+ let service: AccessibilitySettingsService;
+ let cookieService: CookieServiceMock;
+ let authService: AuthServiceStub;
+ let ePersonService: EPersonDataService;
+ let klaroService: KlaroServiceStub;
+ let appConfig: AppConfig;
+
+ beforeEach(() => {
+ cookieService = new CookieServiceMock();
+ authService = new AuthServiceStub();
+ klaroService = new KlaroServiceStub();
+ appConfig = { accessibility: { cookieExpirationDuration: 10 } } as AppConfig;
+
+ klaroService.getSavedPreferences.and.returnValue(of({ accessibility: true }));
+
+ ePersonService = jasmine.createSpyObj('ePersonService', {
+ createPatchFromCache: of([{
+ op: 'add',
+ value: null,
+ }]),
+ patch: of({}),
+ });
+
+ service = new AccessibilitySettingsService(
+ cookieService as unknown as CookieService,
+ authService as unknown as AuthService,
+ ePersonService,
+ klaroService,
+ appConfig,
+ );
+ });
+
+ describe('get', () => {
+ it('should return the setting if it is set', () => {
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.getAll = jasmine.createSpy('getAll').and.returnValue(of(settings));
+
+ service.get('notificationTimeOut', 'default').subscribe(value =>
+ expect(value).toEqual('1000'),
+ );
+ });
+
+ it('should return the default value if the setting is not set', () => {
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.getAll = jasmine.createSpy('getAll').and.returnValue(of(settings));
+
+ service.get('liveRegionTimeOut', 'default').subscribe(value =>
+ expect(value).toEqual('default'),
+ );
+ });
+ });
+
+ describe('getAsNumber', () => {
+ it('should return the setting as number if the value for the setting can be parsed to a number', () => {
+ service.get = jasmine.createSpy('get').and.returnValue(of('1000'));
+
+ service.getAsNumber('notificationTimeOut').subscribe(value =>
+ expect(value).toEqual(1000),
+ );
+ });
+
+ it('should return the default value if no value is set for the setting', () => {
+ service.get = jasmine.createSpy('get').and.returnValue(of(null));
+
+ service.getAsNumber('notificationTimeOut', 123).subscribe(value =>
+ expect(value).toEqual(123),
+ );
+ });
+
+ it('should return the default value if the value for the setting can not be parsed to a number', () => {
+ service.get = jasmine.createSpy('get').and.returnValue(of('text'));
+
+ service.getAsNumber('notificationTimeOut', 123).subscribe(value =>
+ expect(value).toEqual(123),
+ );
+ });
+ });
+
+ describe('getAll', () => {
+ it('should attempt to get the settings from metadata first', () => {
+ service.getAllSettingsFromAuthenticatedUserMetadata =
+ jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of({ }));
+
+ service.getAll().subscribe();
+ expect(service.getAllSettingsFromAuthenticatedUserMetadata).toHaveBeenCalled();
+ });
+
+ it('should attempt to get the settings from the cookie if the settings from metadata are empty', () => {
+ service.getAllSettingsFromAuthenticatedUserMetadata =
+ jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of({ }));
+
+ service.getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({ });
+
+ service.getAll().subscribe();
+ expect(service.getAllSettingsFromCookie).toHaveBeenCalled();
+ });
+
+ it('should not attempt to get the settings from the cookie if the settings from metadata are not empty', () => {
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.getAllSettingsFromAuthenticatedUserMetadata =
+ jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of(settings));
+
+ service.getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({ });
+
+ service.getAll().subscribe();
+ expect(service.getAllSettingsFromCookie).not.toHaveBeenCalled();
+ });
+
+ it('should return an empty object if both are empty', () => {
+ service.getAllSettingsFromAuthenticatedUserMetadata =
+ jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of({ }));
+
+ service.getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({ });
+
+ service.getAll().subscribe(value => expect(value).toEqual({}));
+ });
+ });
+
+ describe('getAllSettingsFromCookie', () => {
+ it('should retrieve the settings from the cookie', () => {
+ cookieService.get = jasmine.createSpy();
+
+ service.getAllSettingsFromCookie();
+ expect(cookieService.get).toHaveBeenCalledWith(ACCESSIBILITY_COOKIE);
+ });
+ });
+
+ describe('getAllSettingsFromAuthenticatedUserMetadata', () => {
+ it('should retrieve all settings from the user\'s metadata', () => {
+ const settings = { 'liveRegionTimeOut': '1000' };
+
+ const user = new EPerson();
+ user.setMetadata(ACCESSIBILITY_SETTINGS_METADATA_KEY, null, JSON.stringify(settings));
+
+ authService.getAuthenticatedUserFromStoreIfAuthenticated =
+ jasmine.createSpy('getAuthenticatedUserFromStoreIfAuthenticated').and.returnValue(of(user));
+
+ service.getAllSettingsFromAuthenticatedUserMetadata().subscribe(value =>
+ expect(value).toEqual(settings),
+ );
+ });
+ });
+
+ describe('set', () => {
+ it('should correctly update the chosen setting', () => {
+ service.updateSettings = jasmine.createSpy('updateSettings');
+
+ service.set('liveRegionTimeOut', '500');
+ expect(service.updateSettings).toHaveBeenCalledWith({ liveRegionTimeOut: '500' });
+ });
+ });
+
+ describe('setSettings', () => {
+ beforeEach(() => {
+ service.setSettingsInCookie = jasmine.createSpy('setSettingsInCookie').and.returnValue(of('cookie'));
+ });
+
+ it('should attempt to set settings in metadata', () => {
+ service.setSettingsInAuthenticatedUserMetadata =
+ jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of('failed'));
+
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.setSettings(settings).subscribe();
+ expect(service.setSettingsInAuthenticatedUserMetadata).toHaveBeenCalledWith(settings);
+ });
+
+ it('should set settings in cookie if metadata failed', () => {
+ service.setSettingsInAuthenticatedUserMetadata =
+ jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of(false));
+
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.setSettings(settings).subscribe();
+ expect(service.setSettingsInCookie).toHaveBeenCalled();
+ });
+
+ it('should not set settings in cookie if metadata succeeded', () => {
+ service.setSettingsInAuthenticatedUserMetadata =
+ jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of('metadata'));
+
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.setSettings(settings).subscribe();
+ expect(service.setSettingsInCookie).not.toHaveBeenCalled();
+ });
+
+ it('should return \'metadata\' if settings are stored in metadata', () => {
+ service.setSettingsInAuthenticatedUserMetadata =
+ jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of('metadata'));
+
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.setSettings(settings).subscribe(value =>
+ expect(value).toEqual('metadata'),
+ );
+ });
+
+ it('should return \'cookie\' if settings are stored in cookie', () => {
+ service.setSettingsInAuthenticatedUserMetadata =
+ jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of(false));
+
+ const settings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.setSettings(settings).subscribe(value =>
+ expect(value).toEqual('cookie'),
+ );
+ });
+ });
+
+ describe('updateSettings', () => {
+ it('should call setSettings with the updated settings', () => {
+ const beforeSettings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ };
+
+ service.getAll = jasmine.createSpy('getAll').and.returnValue(of(beforeSettings));
+ service.setSettings = jasmine.createSpy('setSettings').and.returnValue(of('cookie'));
+
+ const newSettings: AccessibilitySettings = {
+ liveRegionTimeOut: '2000',
+ };
+
+ const combinedSettings: AccessibilitySettings = {
+ notificationTimeOut: '1000',
+ liveRegionTimeOut: '2000',
+ };
+
+ service.updateSettings(newSettings).subscribe();
+ expect(service.setSettings).toHaveBeenCalledWith(combinedSettings);
+ });
+ });
+
+ describe('setSettingsInAuthenticatedUserMetadata', () => {
+ beforeEach(() => {
+ service.setSettingsInMetadata = jasmine.createSpy('setSettingsInMetadata').and.returnValue(of(null));
+ });
+
+ it('should store settings in metadata when the user is authenticated', fakeAsync(() => {
+ const user = new EPerson();
+ authService.getAuthenticatedUserFromStoreIfAuthenticated = jasmine.createSpy().and.returnValue(of(user));
+
+ service.setSettingsInAuthenticatedUserMetadata({}).subscribe();
+ flush();
+
+ expect(service.setSettingsInMetadata).toHaveBeenCalled();
+ }));
+
+ it('should emit "failed" when the user is not authenticated', fakeAsync(() => {
+ authService.getAuthenticatedUserFromStoreIfAuthenticated = jasmine.createSpy().and.returnValue(of(null));
+
+ service.setSettingsInAuthenticatedUserMetadata({})
+ .subscribe(value => expect(value).toEqual('failed'));
+ flush();
+
+ expect(service.setSettingsInMetadata).not.toHaveBeenCalled();
+ }));
+ });
+
+ describe('setSettingsInMetadata', () => {
+ const ePerson = new EPerson();
+
+ beforeEach(() => {
+ ePerson.setMetadata = jasmine.createSpy('setMetadata');
+ ePerson.removeMetadata = jasmine.createSpy('removeMetadata');
+ });
+
+ it('should set the settings in metadata', () => {
+ service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }).subscribe();
+ expect(ePerson.setMetadata).toHaveBeenCalled();
+ });
+
+ it('should remove the metadata when the settings are emtpy', () => {
+ service.setSettingsInMetadata(ePerson, {}).subscribe();
+ expect(ePerson.setMetadata).not.toHaveBeenCalled();
+ expect(ePerson.removeMetadata).toHaveBeenCalled();
+ });
+
+ it('should create a patch with the metadata changes', () => {
+ service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }).subscribe();
+ expect(ePersonService.createPatchFromCache).toHaveBeenCalled();
+ });
+
+ it('should send the patch request', () => {
+ service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }).subscribe();
+ expect(ePersonService.patch).toHaveBeenCalled();
+ });
+
+ it('should emit "metadata" when the update succeeded', fakeAsync(() => {
+ ePersonService.patch = jasmine.createSpy().and.returnValue(createSuccessfulRemoteDataObject$({}));
+
+ service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' })
+ .subscribe(value => {
+ expect(value).toEqual('metadata');
+ });
+
+ flush();
+ }));
+
+ it('should emit "failed" when the update failed', fakeAsync(() => {
+ ePersonService.patch = jasmine.createSpy().and.returnValue(createFailedRemoteDataObject$());
+
+ service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' })
+ .subscribe(value => {
+ expect(value).toEqual('failed');
+ });
+
+ flush();
+ }));
+ });
+
+ describe('setSettingsInCookie', () => {
+ beforeEach(() => {
+ cookieService.set = jasmine.createSpy('set');
+ cookieService.remove = jasmine.createSpy('remove');
+ });
+
+ it('should fail to store settings in the cookie when the user has not accepted the cookie', fakeAsync(() => {
+ klaroService.getSavedPreferences.and.returnValue(of({ accessibility: false }));
+
+ service.setSettingsInCookie({ ['liveRegionTimeOut']: '500' }).subscribe(value => {
+ expect(value).toEqual('failed');
+ });
+ flush();
+ expect(cookieService.set).not.toHaveBeenCalled();
+ }));
+
+ it('should store the settings in a cookie', fakeAsync(() => {
+ service.setSettingsInCookie({ ['liveRegionTimeOut']: '500' }).subscribe(value => {
+ expect(value).toEqual('cookie');
+ });
+ flush();
+ expect(cookieService.set).toHaveBeenCalled();
+ }));
+
+ it('should remove the cookie when the settings are empty', fakeAsync(() => {
+ service.setSettingsInCookie({}).subscribe(value => {
+ expect(value).toEqual('cookie');
+ });
+
+ flush();
+
+ expect(cookieService.set).not.toHaveBeenCalled();
+ expect(cookieService.remove).toHaveBeenCalled();
+ }));
+ });
+
+ describe('convertFormValuesToStoredValues', () => {
+ it('should reset the notificationTimeOut when timeOut is enabled but set to "0"', () => {
+ const formValues: AccessibilitySettingsFormValues = {
+ notificationTimeOutEnabled: true,
+ notificationTimeOut: '0',
+ liveRegionTimeOut: null,
+ };
+
+ const storedValues: FullAccessibilitySettings = service.convertFormValuesToStoredValues(formValues);
+ expect('notificationTimeOut' in storedValues).toBeFalse();
+ });
+ });
+
+ it('should keep the notificationTimeOut when timeOut is enabled and differs from "0"', () => {
+ const formValues: AccessibilitySettingsFormValues = {
+ notificationTimeOutEnabled: true,
+ notificationTimeOut: '3',
+ liveRegionTimeOut: null,
+ };
+
+ const storedValues: FullAccessibilitySettings = service.convertFormValuesToStoredValues(formValues);
+ expect('notificationTimeOut' in storedValues).toBeTrue();
+ });
+});
diff --git a/src/app/accessibility/accessibility-settings.service.stub.ts b/src/app/accessibility/accessibility-settings.service.stub.ts
new file mode 100644
index 00000000000..d991c633941
--- /dev/null
+++ b/src/app/accessibility/accessibility-settings.service.stub.ts
@@ -0,0 +1,45 @@
+import { of } from 'rxjs';
+
+import { AccessibilitySettingsService } from './accessibility-settings.service';
+
+export function getAccessibilitySettingsServiceStub(): AccessibilitySettingsService {
+ return new AccessibilitySettingsServiceStub() as unknown as AccessibilitySettingsService;
+}
+
+export class AccessibilitySettingsServiceStub {
+ getAllAccessibilitySettingKeys = jasmine.createSpy('getAllAccessibilitySettingKeys').and.returnValue([]);
+
+ get = jasmine.createSpy('get').and.returnValue(of(null));
+
+ getAsNumber = jasmine.createSpy('getAsNumber').and.returnValue(of(0));
+
+ getAll = jasmine.createSpy('getAll').and.returnValue(of({}));
+
+ getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({});
+
+ getAllSettingsFromAuthenticatedUserMetadata = jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata')
+ .and.returnValue(of({}));
+
+ set = jasmine.createSpy('setSettings').and.returnValue(of('cookie'));
+
+ updateSettings = jasmine.createSpy('updateSettings').and.returnValue(of('cookie'));
+
+ setSettingsInAuthenticatedUserMetadata = jasmine.createSpy('setSettingsInAuthenticatedUserMetadata')
+ .and.returnValue(of(false));
+
+ setSettingsInMetadata = jasmine.createSpy('setSettingsInMetadata').and.returnValue(of(false));
+
+ setSettingsInCookie = jasmine.createSpy('setSettingsInCookie');
+
+ getInputType = jasmine.createSpy('getInputType').and.returnValue('text');
+
+ convertFormValuesToStoredValues = jasmine.createSpy('convertFormValuesToStoredValues').and.returnValue({});
+
+ convertStoredValuesToFormValues = jasmine.createSpy('convertStoredValuesToFormValues').and.returnValue({});
+
+ getDefaultValue = jasmine.createSpy('getPlaceholder').and.returnValue('placeholder');
+
+ isValid = jasmine.createSpy('isValid').and.returnValue(true);
+
+ formValuesValid = jasmine.createSpy('allValid').and.returnValue(true);
+}
diff --git a/src/app/accessibility/accessibility-settings.service.ts b/src/app/accessibility/accessibility-settings.service.ts
new file mode 100644
index 00000000000..b6bd2e8f1e6
--- /dev/null
+++ b/src/app/accessibility/accessibility-settings.service.ts
@@ -0,0 +1,381 @@
+import {
+ Inject,
+ Injectable,
+ Optional,
+} from '@angular/core';
+import cloneDeep from 'lodash/cloneDeep';
+import {
+ combineLatest,
+ Observable,
+ of,
+ switchMap,
+} from 'rxjs';
+import {
+ map,
+ take,
+} from 'rxjs/operators';
+
+import {
+ APP_CONFIG,
+ AppConfig,
+} from '../../config/app-config.interface';
+import { environment } from '../../environments/environment';
+import { AuthService } from '../core/auth/auth.service';
+import { EPersonDataService } from '../core/eperson/eperson-data.service';
+import { EPerson } from '../core/eperson/models/eperson.model';
+import { CookieService } from '../core/services/cookie.service';
+import { getFirstCompletedRemoteData } from '../core/shared/operators';
+import { KlaroService } from '../shared/cookies/klaro.service';
+import {
+ hasNoValue,
+ hasValue,
+ isNotEmpty,
+} from '../shared/empty.util';
+import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
+
+/**
+ * Name of the cookie used to store the settings locally
+ */
+export const ACCESSIBILITY_COOKIE = 'dsAccessibilityCookie';
+
+/**
+ * Name of the metadata field to store settings on the ePerson
+ */
+export const ACCESSIBILITY_SETTINGS_METADATA_KEY = 'dspace.accessibility.settings';
+
+/**
+ * Array containing all possible accessibility settings.
+ * When adding new settings, make sure to add the new setting to the accessibility-settings component form.
+ * The converter methods to convert from stored format to form format (and vice-versa) need to be updated as well.
+ */
+export const accessibilitySettingKeys = ['notificationTimeOut', 'liveRegionTimeOut'] as const;
+
+/**
+ * Type representing the possible accessibility settings
+ */
+export type AccessibilitySetting = typeof accessibilitySettingKeys[number];
+
+/**
+ * Type representing an object that contains accessibility settings values for all accessibility settings.
+ */
+export type FullAccessibilitySettings = { [key in AccessibilitySetting]: string };
+
+/**
+ * Type representing an object that contains accessibility settings values for some accessibility settings.
+ */
+export type AccessibilitySettings = Partial;
+
+/**
+ * The accessibility settings object format used by the accessibility-settings component form.
+ */
+export interface AccessibilitySettingsFormValues {
+ notificationTimeOutEnabled: boolean,
+ notificationTimeOut: string,
+ liveRegionTimeOut: string,
+}
+
+/**
+ * Service handling the retrieval and configuration of accessibility settings.
+ *
+ * This service stores the configured settings in either a cookie or on the user's metadata depending on whether
+ * the user is authenticated.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class AccessibilitySettingsService {
+
+ constructor(
+ protected cookieService: CookieService,
+ protected authService: AuthService,
+ protected ePersonService: EPersonDataService,
+ @Optional() protected klaroService: KlaroService,
+ @Inject(APP_CONFIG) protected appConfig: AppConfig,
+ ) {
+ }
+
+ /**
+ * Get the stored value for the provided {@link AccessibilitySetting}. If the value does not exist or if it is empty,
+ * the provided defaultValue is emitted instead.
+ */
+ get(setting: AccessibilitySetting, defaultValue: string = null): Observable {
+ return this.getAll().pipe(
+ map(settings => settings[setting]),
+ map(value => isNotEmpty(value) ? value : defaultValue),
+ );
+ }
+
+ /**
+ * Get the stored value for the provided {@link AccessibilitySetting} as a number. If the stored value
+ * could not be converted to a number, the value of the defaultValue parameter is emitted instead.
+ */
+ getAsNumber(setting: AccessibilitySetting, defaultValue: number = null): Observable {
+ return this.get(setting).pipe(
+ map(value => hasValue(value) ? parseInt(value, 10) : NaN),
+ map(number => !isNaN(number) ? number : defaultValue),
+ );
+ }
+
+ /**
+ * Get all currently stored accessibility settings
+ */
+ getAll(): Observable {
+ return this.getAllSettingsFromAuthenticatedUserMetadata().pipe(
+ map(value => isNotEmpty(value) ? value : this.getAllSettingsFromCookie()),
+ map(value => isNotEmpty(value) ? value : {}),
+ );
+ }
+
+ /**
+ * Get all settings from the accessibility settings cookie
+ */
+ getAllSettingsFromCookie(): AccessibilitySettings {
+ return this.cookieService.get(ACCESSIBILITY_COOKIE);
+ }
+
+ /**
+ * Attempts to retrieve all settings from the authenticated user's metadata.
+ * Returns an empty object when no user is authenticated.
+ */
+ getAllSettingsFromAuthenticatedUserMetadata(): Observable {
+ return this.authService.getAuthenticatedUserFromStoreIfAuthenticated().pipe(
+ take(1),
+ map(user => hasValue(user) && hasValue(user.firstMetadataValue(ACCESSIBILITY_SETTINGS_METADATA_KEY)) ?
+ JSON.parse(user.firstMetadataValue(ACCESSIBILITY_SETTINGS_METADATA_KEY)) :
+ {},
+ ),
+ );
+ }
+
+ /**
+ * Set a single accessibility setting value, leaving all other settings unchanged.
+ * When setting all values, {@link AccessibilitySettingsService#setSettings} should be used.
+ * When updating multiple values, {@link AccessibilitySettingsService#updateSettings} should be used.
+ *
+ * Returns 'cookie' when the changes were stored in the cookie.
+ * Returns 'metadata' when the changes were stored in metadata.
+ * Returns 'failed' when both options failed.
+ */
+ set(setting: AccessibilitySetting, value: string): Observable<'metadata' | 'cookie' | 'failed'> {
+ return this.updateSettings({ [setting]: value });
+ }
+
+ /**
+ * Set all accessibility settings simultaneously.
+ * This method removes existing settings if they are missing from the provided {@link AccessibilitySettings} object.
+ * Removes all settings if the provided object is empty.
+ *
+ * Returns 'cookie' when the changes were stored in the cookie.
+ * Returns 'metadata' when the changes were stored in metadata.
+ * Returns 'failed' when both options failed.
+ */
+ setSettings(settings: AccessibilitySettings): Observable<'metadata' | 'cookie' | 'failed'> {
+ return this.setSettingsInAuthenticatedUserMetadata(settings).pipe(
+ take(1),
+ map(saveLocation => saveLocation === 'metadata'),
+ switchMap((savedInMetadata) =>
+ savedInMetadata ? ofMetadata() : this.setSettingsInCookie(settings),
+ ),
+ );
+ }
+
+ /**
+ * Update multiple accessibility settings simultaneously.
+ * This method does not change the settings that are missing from the provided {@link AccessibilitySettings} object.
+ *
+ * Returns 'cookie' when the changes were stored in the cookie.
+ * Returns 'metadata' when the changes were stored in metadata.
+ * Returns 'failed' when both options failed.
+ */
+ updateSettings(settings: AccessibilitySettings): Observable<'metadata' | 'cookie' | 'failed'> {
+ return this.getAll().pipe(
+ take(1),
+ map(currentSettings => Object.assign({}, currentSettings, settings)),
+ switchMap(newSettings => this.setSettings(newSettings)),
+ );
+ }
+
+ /**
+ * Attempts to set the provided settings on the currently authorized user's metadata.
+ * Emits false when no user is authenticated or when the metadata update failed.
+ * Emits 'metadata' when the metadata update succeeded, and 'failed' otherwise.
+ */
+ setSettingsInAuthenticatedUserMetadata(settings: AccessibilitySettings): Observable<'metadata' | 'failed'> {
+ return this.authService.getAuthenticatedUserFromStoreIfAuthenticated().pipe(
+ take(1),
+ switchMap(user => {
+ if (hasValue(user)) {
+ // EPerson has to be cloned, otherwise the EPerson's metadata can't be modified
+ const clonedUser = cloneDeep(user);
+ return this.setSettingsInMetadata(clonedUser, settings);
+ } else {
+ return ofFailed();
+ }
+ }),
+ );
+ }
+
+ /**
+ * Attempts to set the provided settings on the user's metadata.
+ * Emits false when the update failed, true when the update succeeded.
+ */
+ setSettingsInMetadata(
+ user: EPerson,
+ settings: AccessibilitySettings,
+ ): Observable<'metadata' | 'failed'> {
+ if (isNotEmpty(settings)) {
+ user.setMetadata(ACCESSIBILITY_SETTINGS_METADATA_KEY, null, JSON.stringify(settings));
+ } else {
+ user.removeMetadata(ACCESSIBILITY_SETTINGS_METADATA_KEY);
+ }
+
+ return this.ePersonService.createPatchFromCache(user).pipe(
+ take(1),
+ switchMap(operations =>
+ isNotEmpty(operations) ? this.ePersonService.patch(user, operations) : createSuccessfulRemoteDataObject$({})),
+ getFirstCompletedRemoteData(),
+ switchMap(rd => rd.hasSucceeded ? ofMetadata() : ofFailed()),
+ );
+ }
+
+ /**
+ * Attempts to set the provided settings in a cookie.
+ * Emits 'failed' when setting in a cookie failed due to the cookie not being accepted, 'cookie' when it succeeded.
+ */
+ setSettingsInCookie(settings: AccessibilitySettings): Observable<'cookie' | 'failed'> {
+ if (hasNoValue(this.klaroService)) {
+ return of('failed');
+ }
+
+ return this.klaroService.getSavedPreferences().pipe(
+ map(preferences => preferences.accessibility),
+ map((accessibilityCookieAccepted: boolean) => {
+ if (accessibilityCookieAccepted) {
+ if (isNotEmpty(settings)) {
+ this.cookieService.set(ACCESSIBILITY_COOKIE, settings, { expires: this.appConfig.accessibility.cookieExpirationDuration });
+ } else {
+ this.cookieService.remove(ACCESSIBILITY_COOKIE);
+ }
+
+ return 'cookie';
+ } else {
+ return 'failed';
+ }
+ }),
+ );
+ }
+
+ /**
+ * Clears all settings in the cookie and attempts to clear settings in metadata.
+ * Emits an array mentioning which settings succeeded or failed.
+ */
+ clearSettings(): Observable<['cookie' | 'failed', 'metadata' | 'failed']> {
+ return combineLatest([
+ this.setSettingsInCookie({}),
+ this.setSettingsInAuthenticatedUserMetadata({}),
+ ]);
+ }
+
+ /**
+ * Retrieve the default value to be used for the provided AccessibilitySetting.
+ * Returns an empty string when no default value is specified for the provided setting.
+ */
+ getDefaultValue(setting: AccessibilitySetting): string {
+ switch (setting) {
+ case 'notificationTimeOut':
+ return millisecondsToSeconds(environment.notifications.timeOut.toString());
+ case 'liveRegionTimeOut':
+ return millisecondsToSeconds(environment.liveRegion.messageTimeOutDurationMs.toString());
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Convert values in the provided accessibility settings object to values ready to be stored.
+ */
+ convertFormValuesToStoredValues(settings: AccessibilitySettingsFormValues): FullAccessibilitySettings {
+ const storedValues = {
+ notificationTimeOut: settings.notificationTimeOutEnabled ?
+ secondsToMilliseconds(settings.notificationTimeOut) : '0',
+ liveRegionTimeOut: secondsToMilliseconds(settings.liveRegionTimeOut),
+ };
+
+ // When the user enables the timeout but does not change the timeout duration from 0,
+ // it is removed from the values to be stored so the default value is used.
+ // Keeping it at 0 would mean the notifications are not automatically removed.
+ if (settings.notificationTimeOutEnabled && settings.notificationTimeOut === '0') {
+ delete storedValues.notificationTimeOut;
+ }
+
+ return storedValues;
+ }
+
+ /**
+ * Convert values in the provided accessibility settings object to values ready to show in the form.
+ */
+ convertStoredValuesToFormValues(settings: AccessibilitySettings): AccessibilitySettingsFormValues {
+ return {
+ notificationTimeOutEnabled: parseFloat(settings.notificationTimeOut) !== 0,
+ notificationTimeOut: millisecondsToSeconds(settings.notificationTimeOut),
+ liveRegionTimeOut: millisecondsToSeconds(settings.liveRegionTimeOut),
+ };
+ }
+
+ /**
+ * Returns true if the provided AccessibilitySetting is valid in regard to the provided formValues.
+ */
+ isValid(setting: AccessibilitySetting, formValues: AccessibilitySettingsFormValues): boolean {
+ switch (setting) {
+ case 'notificationTimeOut':
+ return formValues.notificationTimeOutEnabled ?
+ hasNoValue(formValues.notificationTimeOut) || parseFloat(formValues.notificationTimeOut) > 0 :
+ true;
+ case 'liveRegionTimeOut':
+ return hasNoValue(formValues.liveRegionTimeOut) || parseFloat(formValues.liveRegionTimeOut) > 0;
+ default:
+ throw new Error(`Unhandled accessibility setting during validity check: ${setting}`);
+ }
+ }
+
+ /**
+ * Returns true if all settings in the provided AccessibilitySettingsFormValues object are valid
+ */
+ formValuesValid(formValues: AccessibilitySettingsFormValues) {
+ return accessibilitySettingKeys.every(setting => this.isValid(setting, formValues));
+ }
+}
+
+/**
+ * Converts a string representing seconds to a string representing milliseconds
+ * Returns null if the input could not be parsed to a float
+ */
+function secondsToMilliseconds(secondsStr: string): string {
+ const seconds = parseFloat(secondsStr);
+ if (isNaN(seconds)) {
+ return null;
+ } else {
+ return (seconds * 1000).toString();
+ }
+}
+
+/**
+ * Converts a string representing milliseconds to a string representing seconds
+ * Returns null if the input could not be parsed to a float
+ */
+function millisecondsToSeconds(millisecondsStr: string): string {
+ const milliseconds = parseFloat(millisecondsStr);
+ if (isNaN(milliseconds)) {
+ return null;
+ } else {
+ return (milliseconds / 1000).toString();
+ }
+}
+
+function ofMetadata(): Observable<'metadata'> {
+ return of('metadata');
+}
+
+function ofFailed(): Observable<'failed'> {
+ return of('failed');
+}
diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html
index 912e931f408..ccdfd3dfec2 100644
--- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html
+++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html
@@ -9,9 +9,9 @@