diff --git a/config/config.example.yml b/config/config.example.yml index f345c36666..18c8363d45 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -609,3 +609,8 @@ geospatialMapViewer: defaultCentrePoint: lat: 41.015137 lng: 28.979530 + +# Configuration for storing accessibility settings, used by the AccessibilitySettingsService +accessibility: + # The duration in days after which the accessibility settings cookie expires + cookieExpirationDuration: 7 diff --git a/cypress.config.ts b/cypress.config.ts index 36d8120342..253644deab 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME: 'People', // Account used to test basic submission process DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', + DSPACE_TEST_SUBMIT_USER_UUID: '914955b1-cf2e-4884-8af7-a166aa24cf73', DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', // Administrator users group DSPACE_ADMINISTRATOR_GROUP: 'e59f5659-bff9-451e-b28f-439e7bd467e4' diff --git a/cypress/e2e/health-page.cy.ts b/cypress/e2e/health-page.cy.ts index c702fa72d7..7876a9d43c 100644 --- a/cypress/e2e/health-page.cy.ts +++ b/cypress/e2e/health-page.cy.ts @@ -4,13 +4,14 @@ import { Options } from 'cypress-axe'; beforeEach(() => { // Must login as an Admin to see the page + cy.intercept('GET', '/server/actuator/health').as('status'); + cy.intercept('GET', '/server/actuator/info').as('info'); cy.visit('/health'); cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); }); describe('Health Page > Status Tab', () => { it('should pass accessibility tests', () => { - cy.intercept('GET', '/server/actuator/health').as('status'); cy.wait('@status'); cy.get('a[data-test="health-page.status-tab"]').click(); @@ -36,7 +37,6 @@ describe('Health Page > Status Tab', () => { describe('Health Page > Info Tab', () => { it('should pass accessibility tests', () => { - cy.intercept('GET', '/server/actuator/info').as('info'); cy.wait('@info'); cy.get('a[data-test="health-page.info-tab"]').click(); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 1dfc4ff8b4..64aa1f7b7c 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -56,7 +56,7 @@ before(() => { beforeEach(() => { // Pre-agree to all Orejime cookies by setting the orejime-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('orejime-anonymous', '{"authentication":true,"preferences":true,"acknowledgement":true,"google-analytics":true,"correlation-id":true}'); + cy.setCookie('orejime-anonymous', '{"authentication":true,"preferences":true,"acknowledgement":true,"google-analytics":true,"correlation-id":true,"accessibility":true}'); // Remove any CSRF cookies saved from prior tests cy.clearCookie(DSPACE_XSRF_COOKIE); diff --git a/src/app/accessibility/accessibility-settings.config.ts b/src/app/accessibility/accessibility-settings.config.ts new file mode 100644 index 0000000000..1852579c3d --- /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 0000000000..98c8b3d95d --- /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 { OrejimeServiceStub } from '../shared/cookies/orejime.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 orejimeService: OrejimeServiceStub; + let appConfig: AppConfig; + + beforeEach(() => { + cookieService = new CookieServiceMock(); + authService = new AuthServiceStub(); + orejimeService = new OrejimeServiceStub(); + appConfig = { accessibility: { cookieExpirationDuration: 10 } } as AppConfig; + + orejimeService.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, + orejimeService, + 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(() => { + orejimeService.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 0000000000..d991c63394 --- /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 0000000000..c9d8a38a6d --- /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 { OrejimeService } from '../shared/cookies/orejime.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 orejimeService: OrejimeService, + @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.orejimeService)) { + return of('failed'); + } + + return this.orejimeService.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/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index b81de56a69..a4a85d8e67 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -55,6 +55,7 @@ import { import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, } from '../shared/operators'; import { PageInfo } from '../shared/page-info.model'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -266,6 +267,23 @@ export class AuthService { ); } + /** + * Returns an observable which emits the currently authenticated user from the store, + * or null if the user is not authenticated. + */ + public getAuthenticatedUserFromStoreIfAuthenticated(): Observable { + return this.store.pipe( + select(getAuthenticatedUserId), + switchMap((id: string) => { + if (hasValue(id)) { + return this.epersonService.findById(id).pipe(getFirstSucceededRemoteDataPayload()); + } else { + return observableOf(null); + } + }), + ); + } + /** * Checks if token is present into browser storage and is valid. */ diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 7d87d540e8..297a300d95 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -66,6 +66,10 @@ } +
  • + {{ 'footer.link.accessibility' | translate }} +
  • @if (showPrivacyPolicy) {
  • +

    {{ 'info.accessibility-settings.title' | translate }}

    + +
    +
    + + +
    + +
    + +
    +
    +
    + +
    + + +
    + +
    + {{ 'info.accessibility-settings.notificationTimeOut.invalid' | translate }} +
    +
    + + +
    +
    +
    + +
    + + +
    + +
    + {{ 'info.accessibility-settings.liveRegionTimeOut.invalid' | translate }} +
    +
    + + +
    +
    +
    + +
    + + +
    + +
    + + @if ((isAuthenticated | async) === false && (cookieIsAccepted | async) === false) { +
    + {{ 'info.accessibility-settings.cookie-warning' | translate }} +
    + } + + diff --git a/src/app/info/accessibility-settings/accessibility-settings.component.spec.ts b/src/app/info/accessibility-settings/accessibility-settings.component.spec.ts new file mode 100644 index 0000000000..c4a315a377 --- /dev/null +++ b/src/app/info/accessibility-settings/accessibility-settings.component.spec.ts @@ -0,0 +1,98 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ContextHelpDirective } from 'src/app/shared/context-help.directive'; + +import { AccessibilitySettingsService } from '../../accessibility/accessibility-settings.service'; +import { getAccessibilitySettingsServiceStub } from '../../accessibility/accessibility-settings.service.stub'; +import { AuthService } from '../../core/auth/auth.service'; +import { OrejimeService } from '../../shared/cookies/orejime.service'; +import { OrejimeServiceStub } from '../../shared/cookies/orejime.service.stub'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { AccessibilitySettingsComponent } from './accessibility-settings.component'; + + +describe('AccessibilitySettingsComponent', () => { + let component: AccessibilitySettingsComponent; + let fixture: ComponentFixture; + + let authService: AuthServiceStub; + let settingsService: AccessibilitySettingsService; + let notificationsService: NotificationsServiceStub; + let orejimeService: OrejimeServiceStub; + + beforeEach(waitForAsync(() => { + authService = new AuthServiceStub(); + settingsService = getAccessibilitySettingsServiceStub(); + notificationsService = new NotificationsServiceStub(); + orejimeService = new OrejimeServiceStub(); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: AccessibilitySettingsService, useValue: settingsService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: OrejimeService, useValue: orejimeService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(AccessibilitySettingsComponent, { + remove: { + imports: [ContextHelpDirective], + }, + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccessibilitySettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('On Init', () => { + it('should retrieve the current settings', () => { + expect(settingsService.getAll).toHaveBeenCalled(); + }); + + it('should convert retrieved settings to form format', () => { + expect(settingsService.convertStoredValuesToFormValues).toHaveBeenCalled(); + }); + }); + + describe('saveSettings', () => { + it('should save the settings in the service', () => { + settingsService.setSettings = jasmine.createSpy('setSettings').and.returnValue(of('cookie')); + component.saveSettings(); + expect(settingsService.setSettings).toHaveBeenCalled(); + }); + + it('should convert form settings to stored format', () => { + settingsService.setSettings = jasmine.createSpy('setSettings').and.returnValue(of('cookie')); + component.saveSettings(); + expect(settingsService.convertFormValuesToStoredValues).toHaveBeenCalled(); + }); + + it('should give the user a notification mentioning where the settings were saved', () => { + settingsService.setSettings = jasmine.createSpy('setSettings').and.returnValue(of('cookie')); + component.saveSettings(); + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should give the user a notification mentioning why saving failed, if it failed', () => { + settingsService.setSettings = jasmine.createSpy('setSettings').and.returnValue(of('failed')); + component.saveSettings(); + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/info/accessibility-settings/accessibility-settings.component.ts b/src/app/info/accessibility-settings/accessibility-settings.component.ts new file mode 100644 index 0000000000..23699b14a6 --- /dev/null +++ b/src/app/info/accessibility-settings/accessibility-settings.component.ts @@ -0,0 +1,148 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { UiSwitchModule } from 'ngx-ui-switch'; +import { + BehaviorSubject, + Subscription, + take, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; +import { AlertType } from 'src/app/shared/alert/alert-type'; + +import { + AccessibilitySetting, + AccessibilitySettingsFormValues, + AccessibilitySettingsService, +} from '../../accessibility/accessibility-settings.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { AlertComponent } from '../../shared/alert/alert.component'; +import { ContextHelpDirective } from '../../shared/context-help.directive'; +import { OrejimeService } from '../../shared/cookies/orejime.service'; +import { isEmpty } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +/** + * Component providing the form where users can update accessibility settings. + */ +@Component({ + selector: 'ds-accessibility-settings', + templateUrl: './accessibility-settings.component.html', + imports: [ + CommonModule, + TranslateModule, + FormsModule, + UiSwitchModule, + ContextHelpDirective, + AlertComponent, + ], + standalone: true, +}) +export class AccessibilitySettingsComponent implements OnInit, OnDestroy { + // Redeclared for use in template + protected readonly AlertType = AlertType; + + protected formValues: AccessibilitySettingsFormValues; + + isAuthenticated: BehaviorSubject = new BehaviorSubject(false); + cookieIsAccepted: BehaviorSubject = new BehaviorSubject(false); + + private subscriptions: Subscription[] = []; + + constructor( + protected authService: AuthService, + protected settingsService: AccessibilitySettingsService, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + protected orejimeService: OrejimeService, + ) { + } + + ngOnInit() { + this.updateFormValues(); + + this.subscriptions.push( + this.authService.isAuthenticated().pipe(distinctUntilChanged()) + .subscribe(val => this.isAuthenticated.next(val)), + this.orejimeService.getSavedPreferences().pipe( + map(preferences => preferences?.accessibility === true), + distinctUntilChanged(), + ).subscribe(val => this.cookieIsAccepted.next(val)), + ); + } + + ngOnDestroy() { + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + } + + /** + * Saves the user-configured settings + */ + saveSettings() { + const formValues = this.formValues; + + if (this.settingsService.formValuesValid(formValues)) { + const convertedValues = this.settingsService.convertFormValuesToStoredValues(formValues); + this.settingsService.setSettings(convertedValues).pipe(take(1)).subscribe(location => { + if (location !== 'failed') { + this.notificationsService.success(null, this.translateService.instant('info.accessibility-settings.save-notification.' + location)); + this.updateFormValues(); + } else { + this.notificationsService.error(null, this.translateService.instant('info.accessibility-settings.failed-notification')); + } + }); + } else { + this.notificationsService.error( + null, + this.translateService.instant('info.accessibility-settings.invalid-form-notification'), + ); + } + } + + /** + * Updates the form values with the currently stored accessibility settings and sets the default values for settings + * that have no stored value. + */ + updateFormValues() { + this.settingsService.getAll().pipe(take(1)).subscribe(storedSettings => { + const formValues = this.settingsService.convertStoredValuesToFormValues(storedSettings); + + const settingsRequiringDefaultValue: AccessibilitySetting[] = ['notificationTimeOut', 'liveRegionTimeOut']; + + for (const setting of settingsRequiringDefaultValue) { + if (isEmpty(formValues[setting])) { + const defaultValue = this.settingsService.getDefaultValue(setting); + formValues[setting] = defaultValue; + } + } + + this.formValues = formValues; + }); + } + + /** + * Resets accessibility settings + */ + resetSettings() { + this.settingsService.clearSettings().pipe(take(1)).subscribe(([cookieReset, metadataReset]) => { + if (cookieReset === 'failed' && metadataReset === 'failed') { + this.notificationsService.warning(null, this.translateService.instant('info.accessibility-settings.reset-failed')); + } else { + this.notificationsService.success(null, this.translateService.instant('info.accessibility-settings.reset-notification')); + this.updateFormValues(); + } + }); + } + +} diff --git a/src/app/info/info-routes.ts b/src/app/info/info-routes.ts index 8d70ac740f..4b7decb49c 100644 --- a/src/app/info/info-routes.ts +++ b/src/app/info/info-routes.ts @@ -8,9 +8,11 @@ import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { notifyInfoGuard } from '../core/coar-notify/notify-info/notify-info.guard'; import { feedbackGuard } from '../core/feedback/feedback.guard'; import { hasValue } from '../shared/empty.util'; +import { AccessibilitySettingsComponent } from './accessibility-settings/accessibility-settings.component'; import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end-user-agreement.component'; import { ThemedFeedbackComponent } from './feedback/themed-feedback.component'; import { + ACCESSIBILITY_SETTINGS_PATH, COAR_NOTIFY_SUPPORT, END_USER_AGREEMENT_PATH, FEEDBACK_PATH, @@ -28,6 +30,12 @@ export const ROUTES: Routes = [ data: { title: 'info.feedback.title', breadcrumbKey: 'info.feedback' }, canActivate: [feedbackGuard], }, + { + path: ACCESSIBILITY_SETTINGS_PATH, + component: AccessibilitySettingsComponent, + resolve: { breadcrumb: i18nBreadcrumbResolver }, + data: { title: 'info.accessibility-settings.title', breadcrumbKey: 'info.accessibility-settings' }, + }, environment.info.enableEndUserAgreement ? { path: END_USER_AGREEMENT_PATH, component: ThemedEndUserAgreementComponent, diff --git a/src/app/info/info-routing-paths.ts b/src/app/info/info-routing-paths.ts index cd42dd9c1d..6aa4bfbc72 100644 --- a/src/app/info/info-routing-paths.ts +++ b/src/app/info/info-routing-paths.ts @@ -4,6 +4,7 @@ export const END_USER_AGREEMENT_PATH = 'end-user-agreement'; export const PRIVACY_PATH = 'privacy'; export const FEEDBACK_PATH = 'feedback'; export const COAR_NOTIFY_SUPPORT = 'coar-notify-support'; +export const ACCESSIBILITY_SETTINGS_PATH = 'accessibility'; export function getEndUserAgreementPath() { return getSubPath(END_USER_AGREEMENT_PATH); @@ -21,6 +22,10 @@ export function getCOARNotifySupportPath(): string { return getSubPath(COAR_NOTIFY_SUPPORT); } +export function getAccessibilitySettingsPath() { + return getSubPath(ACCESSIBILITY_SETTINGS_PATH); +} + function getSubPath(path: string) { return `${getInfoModulePath()}/${path}`; } diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index eeb6f8e41c..5d21b1578a 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -35,6 +35,15 @@
    + +
    + @if ((groupsRD$ | async); as groupsRD;) { diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index ee2cec645d..ddb8964327 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -6,6 +6,7 @@ import { waitForAsync, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; import { StoreModule } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; @@ -120,6 +121,7 @@ describe('ProfilePageComponent', () => { RouterModule.forRoot([]), ProfilePageComponent, VarDirective, + NoopAnimationsModule, ], providers: [ { provide: EPersonDataService, useValue: epersonService }, diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 7a198e34fa..db727eed16 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -7,6 +7,7 @@ import { OnInit, ViewChild, } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { TranslateModule, TranslateService, @@ -41,6 +42,7 @@ import { getRemoteDataPayload, } from '../core/shared/operators'; import { SuggestionsNotificationComponent } from '../notifications/suggestions/notification/suggestions-notification.component'; +import { AlertComponent } from '../shared/alert/alert.component'; import { hasValue, isNotEmpty, @@ -72,6 +74,8 @@ import { ProfilePageSecurityFormComponent } from './profile-page-security-form/p PaginationComponent, ThemedLoadingComponent, ErrorComponent, + RouterModule, + AlertComponent, ], standalone: true, }) diff --git a/src/app/root/root.component.spec.ts b/src/app/root/root.component.spec.ts index 1abc3ffd17..77b39d2908 100644 --- a/src/app/root/root.component.spec.ts +++ b/src/app/root/root.component.spec.ts @@ -8,6 +8,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; +import { AccessibilitySettingsService } from '../accessibility/accessibility-settings.service'; +import { AccessibilitySettingsServiceStub } from '../accessibility/accessibility-settings.service.stub'; import { ThemedAdminSidebarComponent } from '../admin/admin-sidebar/themed-admin-sidebar.component'; import { ThemedBreadcrumbsComponent } from '../breadcrumbs/themed-breadcrumbs.component'; import { ThemedFooterComponent } from '../footer/themed-footer.component'; @@ -41,6 +43,7 @@ describe('RootComponent', () => { { provide: MenuService, useValue: new MenuServiceStub() }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: AccessibilitySettingsService, useValue: new AccessibilitySettingsServiceStub() }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) diff --git a/src/app/shared/cookies/orejime-configuration.ts b/src/app/shared/cookies/orejime-configuration.ts index c7c616121a..da383a9a34 100644 --- a/src/app/shared/cookies/orejime-configuration.ts +++ b/src/app/shared/cookies/orejime-configuration.ts @@ -1,3 +1,4 @@ +import { ACCESSIBILITY_COOKIE } from '../../accessibility/accessibility-settings.service'; import { IMPERSONATING_COOKIE, REDIRECT_COOKIE, @@ -222,6 +223,13 @@ export function getOrejimeConfiguration(_window: NativeWindowRef): any { }, onlyOnce: true, }, + { + name: 'accessibility', + purposes: ['functional'], + required: false, + cookies: [ACCESSIBILITY_COOKIE], + onlyOnce: false, + }, ], }; } diff --git a/src/app/shared/cookies/orejime.service.stub.ts b/src/app/shared/cookies/orejime.service.stub.ts new file mode 100644 index 0000000000..2913433da5 --- /dev/null +++ b/src/app/shared/cookies/orejime.service.stub.ts @@ -0,0 +1,9 @@ +import { of } from 'rxjs'; + +export class OrejimeServiceStub { + initialize = jasmine.createSpy('initialize'); + + showSettings = jasmine.createSpy('showSettings'); + + getSavedPreferences = jasmine.createSpy('getSavedPreferences').and.returnValue(of({})); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts index 0d9ed6ae74..2367615d22 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts @@ -21,6 +21,7 @@ import { } from '@ngx-translate/core'; import { NgxMaskModule } from 'ngx-mask'; import { of } from 'rxjs'; +import { LiveRegionService } from 'src/app/shared/live-region/live-region.service'; import { APP_CONFIG, @@ -28,6 +29,7 @@ import { } from '../../../../../../../config/app-config.interface'; import { environment } from '../../../../../../../environments/environment.test'; import { SubmissionService } from '../../../../../../submission/submission.service'; +import { getLiveRegionServiceStub } from '../../../../../live-region/live-region.service.stub'; import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component'; import { dsDynamicFormControlMapFn } from '../../ds-dynamic-form-control-map-fn'; import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; @@ -63,6 +65,7 @@ describe('DsDynamicFormArrayComponent', () => { { provide: SubmissionService, useValue: {} }, { provide: APP_CONFIG, useValue: environment }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, ], }).overrideComponent(DsDynamicFormArrayComponent, { remove: { diff --git a/src/app/shared/live-region/live-region.service.spec.ts b/src/app/shared/live-region/live-region.service.spec.ts index 375c0d0d69..84f9d44368 100644 --- a/src/app/shared/live-region/live-region.service.spec.ts +++ b/src/app/shared/live-region/live-region.service.spec.ts @@ -1,18 +1,26 @@ import { fakeAsync, - flush, tick, } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { AccessibilitySettingsService } from '../../accessibility/accessibility-settings.service'; +import { getAccessibilitySettingsServiceStub } from '../../accessibility/accessibility-settings.service.stub'; import { UUIDService } from '../../core/shared/uuid.service'; import { LiveRegionService } from './live-region.service'; describe('liveRegionService', () => { let service: LiveRegionService; + let accessibilitySettingsService: AccessibilitySettingsService; beforeEach(() => { + accessibilitySettingsService = getAccessibilitySettingsServiceStub(); + + accessibilitySettingsService.getAsNumber = jasmine.createSpy('getAsNumber').and.returnValue(of(100)); + service = new LiveRegionService( new UUIDService(), + accessibilitySettingsService, ); }); @@ -86,13 +94,16 @@ describe('liveRegionService', () => { expect(results[2]).toEqual(['Message One', 'Message Two']); service.clear(); - flush(); + tick(200); expect(results.length).toEqual(4); expect(results[3]).toEqual([]); })); it('should not pop messages added after clearing within timeOut period', fakeAsync(() => { + // test expects a clear rate of 30 seconds + accessibilitySettingsService.getAsNumber = jasmine.createSpy('getAsNumber').and.returnValue(of(30000)); + const results: string[][] = []; service.getMessages$().subscribe((messages) => { @@ -119,45 +130,6 @@ describe('liveRegionService', () => { 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', () => { diff --git a/src/app/shared/live-region/live-region.service.ts b/src/app/shared/live-region/live-region.service.ts index 7947024091..c972867dbc 100644 --- a/src/app/shared/live-region/live-region.service.ts +++ b/src/app/shared/live-region/live-region.service.ts @@ -1,9 +1,19 @@ import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { + BehaviorSubject, + map, + Observable, + switchMap, + take, + timer, +} from 'rxjs'; import { environment } from '../../../environments/environment'; +import { AccessibilitySettingsService } from '../../accessibility/accessibility-settings.service'; import { UUIDService } from '../../core/shared/uuid.service'; +export const MIN_MESSAGE_DURATION = 200; + /** * 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. @@ -15,6 +25,7 @@ export class LiveRegionService { constructor( protected uuidService: UUIDService, + protected accessibilitySettingsService: AccessibilitySettingsService, ) { } @@ -65,7 +76,12 @@ export class LiveRegionService { addMessage(message: string): string { const uuid = this.uuidService.generate(); this.messages.push({ message, uuid }); - setTimeout(() => this.clearMessageByUUID(uuid), this.messageTimeOutDurationMs); + + this.getConfiguredMessageTimeOutMs().pipe( + take(1), + switchMap(timeOut => timer(timeOut)), + ).subscribe(() => this.clearMessageByUUID(uuid)); + this.emitCurrentMessages(); return uuid; } @@ -116,6 +132,17 @@ export class LiveRegionService { this.liveRegionIsVisible = isVisible; } + /** + * Gets the user-configured timeOut, or the stored timeOut if the user has not configured a timeOut duration. + * Emits {@link MIN_MESSAGE_DURATION} if the configured value is smaller. + */ + getConfiguredMessageTimeOutMs(): Observable { + return this.accessibilitySettingsService.getAsNumber( + 'liveRegionTimeOut', + this.getMessageTimeOutMs(), + ).pipe(map(timeOut => Math.max(timeOut, MIN_MESSAGE_DURATION))); + } + /** * Gets the current message timeOut duration in milliseconds */ diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts index ed02327da9..4bf246f2c8 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts @@ -20,6 +20,8 @@ import { cold } from 'jasmine-marbles'; import uniqueId from 'lodash/uniqueId'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; +import { AccessibilitySettingsService } from '../../../accessibility/accessibility-settings.service'; +import { getAccessibilitySettingsServiceStub } from '../../../accessibility/accessibility-settings.service.stub'; import { AppState } from '../../../app.reducer'; import { LiveRegionService } from '../../live-region/live-region.service'; import { LiveRegionServiceStub } from '../../live-region/live-region.service.stub'; @@ -57,6 +59,7 @@ describe('NotificationsBoardComponent', () => { providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: LiveRegionService, useValue: liveRegionService }, + { provide: AccessibilitySettingsService, useValue: getAccessibilitySettingsServiceStub() }, ChangeDetectorRef, ], }).compileComponents(); // compile template and css diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.ts index e4c55e1e28..84843cf5d9 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.ts +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.ts @@ -12,7 +12,8 @@ import { select, Store, } from '@ngrx/store'; -import difference from 'lodash/difference'; +import cloneDeep from 'lodash/cloneDeep'; +import differenceWith from 'lodash/differenceWith'; import { BehaviorSubject, of as observableOf, @@ -21,6 +22,7 @@ import { import { take } from 'rxjs/operators'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; +import { AccessibilitySettingsService } from '../../../accessibility/accessibility-settings.service'; import { AppState } from '../../../app.reducer'; import { hasNoValue, @@ -66,10 +68,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { public isPaused$: BehaviorSubject = new BehaviorSubject(false); constructor( - private service: NotificationsService, - private store: Store, - private cdr: ChangeDetectorRef, + protected service: NotificationsService, + protected store: Store, + protected cdr: ChangeDetectorRef, protected liveRegionService: LiveRegionService, + protected accessibilitySettingsService: AccessibilitySettingsService, ) { } @@ -80,13 +83,13 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { this.notifications = []; } else if (state.length > this.notifications.length) { // Add - const newElem = difference(state, this.notifications); + const newElem = differenceWith(state, this.notifications, this.byId); newElem.forEach((notification) => { this.add(notification); }); } else { // Remove - const delElem = difference(this.notifications, state); + const delElem = differenceWith(this.notifications, state, this.byId); delElem.forEach((notification) => { this.notifications = this.notifications.filter((item: INotification) => item.id !== notification.id); @@ -96,6 +99,9 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { }); } + private byId = (notificationA: INotification, notificationB: INotification) => + notificationA.id === notificationB.id; + // Add the new notification to the notification array add(item: INotification): void { const toBlock: boolean = this.block(item); @@ -103,8 +109,23 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { if (this.notifications.length >= this.maxStack) { this.notifications.splice(this.notifications.length - 1, 1); } - this.notifications.splice(0, 0, item); - this.addContentToLiveRegion(item); + + // It would be a bit better to handle the retrieval of configured settings in the NotificationsService. + // Due to circular dependencies this is difficult to implement. + this.accessibilitySettingsService.getAsNumber('notificationTimeOut', item.options.timeOut) + .pipe(take(1)).subscribe(timeOut => { + if (timeOut < 0) { + timeOut = 0; + } + + // Deep clone because the unaltered item is read-only + const modifiedNotification = cloneDeep(item); + modifiedNotification.options.timeOut = timeOut; + this.notifications.splice(0, 0, modifiedNotification); + this.addContentToLiveRegion(modifiedNotification); + this.cdr.detectChanges(); + }); + } else { // Remove the notification from the store // This notification was in the store, but not in this.notifications diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts index 08e4fc4f68..d532652097 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts @@ -22,6 +22,8 @@ import { SearchService } from '../../../../../../core/shared/search/search.servi import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { SearchFilterService } from '../../../../../../core/shared/search/search-filter.service'; import { ActivatedRouteStub } from '../../../../../../shared/testing/active-router.stub'; +import { LiveRegionService } from '../../../../../live-region/live-region.service'; +import { getLiveRegionServiceStub } from '../../../../../live-region/live-region.service.stub'; import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; import { PaginationServiceStub } from '../../../../../testing/pagination-service.stub'; import { RouterStub } from '../../../../../testing/router.stub'; @@ -84,6 +86,7 @@ describe('SearchFacetOptionComponent', () => { { provide: SearchConfigurationService, useValue: searchConfigurationService }, { provide: SearchFilterService, useValue: searchFilterService }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(SearchFacetOptionComponent, { diff --git a/src/app/shared/testing/auth-service.stub.ts b/src/app/shared/testing/auth-service.stub.ts index 339127b710..56bfa98434 100644 --- a/src/app/shared/testing/auth-service.stub.ts +++ b/src/app/shared/testing/auth-service.stub.ts @@ -60,6 +60,10 @@ export class AuthServiceStub { return observableOf(EPersonMock); } + getAuthenticatedUserFromStoreIfAuthenticated(): Observable { + return observableOf(EPersonMock); + } + public buildAuthHeader(token?: AuthTokenInfo): string { return `Bearer ${token ? token.accessToken : ''}`; } diff --git a/src/app/submission/sections/accesses/section-accesses.component.spec.ts b/src/app/submission/sections/accesses/section-accesses.component.spec.ts index c49bd74e0d..95e8b00bec 100644 --- a/src/app/submission/sections/accesses/section-accesses.component.spec.ts +++ b/src/app/submission/sections/accesses/section-accesses.component.spec.ts @@ -14,6 +14,7 @@ import { Store } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; +import { LiveRegionService } from 'src/app/shared/live-region/live-region.service'; import { APP_CONFIG, APP_DATA_SERVICES_MAP, @@ -30,6 +31,7 @@ import { DsDynamicTypeBindRelationService } from '../../../shared/form/builder/d import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../shared/form/form.component'; import { FormService } from '../../../shared/form/form.service'; +import { getLiveRegionServiceStub } from '../../../shared/live-region/live-region.service.stub'; import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; import { getMockFormService } from '../../../shared/mocks/form-service.mock'; @@ -125,6 +127,7 @@ describe('SubmissionSectionAccessesComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, FormBuilderService, provideMockStore({}), ], @@ -222,6 +225,7 @@ describe('SubmissionSectionAccessesComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, FormBuilderService, provideMockStore({}), diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 05cdd4a32b..c262850745 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1635,6 +1635,10 @@ "cookies.consent.content-modal.title": "Information that we collect", + "cookies.consent.app.title.accessibility": "Accessibility Settings", + + "cookies.consent.app.description.accessibility": "Required for saving your accessibility settings locally", + "cookies.consent.app.title.authentication": "Authentication", "cookies.consent.app.description.authentication": "Required for signing you in", @@ -1943,6 +1947,8 @@ "footer.copyright": "copyright © 2002-{{ year }}", + "footer.link.accessibility": "Accessibility settings", + "footer.link.dspace": "DSpace software", "footer.link.lyrasis": "LYRASIS", @@ -2191,6 +2197,44 @@ "home.top-level-communities.help": "Select a community to browse its collections.", + "info.accessibility-settings.breadcrumbs": "Accessibility settings", + + "info.accessibility-settings.cookie-warning": "Saving the accessibility settings is currently not possible. Either log in to save the settings in user data, or accept the 'Accessibility Settings' cookie using the 'Cookie Settings' menu at the bottom of the page. Once the cookie has been accepted, you can reload the page to remove this message.", + + "info.accessibility-settings.disableNotificationTimeOut.label": "Automatically close notifications after time out", + + "info.accessibility-settings.disableNotificationTimeOut.hint": "When this toggle is activated, notifications will close automatically after the time out passes. When deactivated, notifications will remain open untill manually closed.", + + "info.accessibility-settings.failed-notification": "Failed to save accessibility settings", + + "info.accessibility-settings.invalid-form-notification": "Did not save. The form contains invalid values.", + + "info.accessibility-settings.liveRegionTimeOut.label": "ARIA Live region time out (in seconds)", + + "info.accessibility-settings.liveRegionTimeOut.hint": "The duration after which a message in the ARIA live region disappears. ARIA live regions are not visible on the page, but provide announcements of notifications (or other actions) to screen readers.", + + "info.accessibility-settings.liveRegionTimeOut.invalid": "Live region time out must be greater than 0", + + "info.accessibility-settings.notificationTimeOut.label": "Notification time out (in seconds)", + + "info.accessibility-settings.notificationTimeOut.hint": "The duration after which a notification disappears.", + + "info.accessibility-settings.notificationTimeOut.invalid": "Notification time out must be greater than 0", + + "info.accessibility-settings.save-notification.cookie": "Successfully saved settings locally.", + + "info.accessibility-settings.save-notification.metadata": "Successfully saved settings on the user profile.", + + "info.accessibility-settings.reset-failed": "Failed to reset. Either log in or accept the 'Accessibility Settings' cookie.", + + "info.accessibility-settings.reset-notification": "Successfully reset settings.", + + "info.accessibility-settings.reset": "Reset accessibility settings", + + "info.accessibility-settings.submit": "Save accessibility settings", + + "info.accessibility-settings.title": "Accessibility settings", + "info.end-user-agreement.accept": "I have read and I agree to the End User Agreement", "info.end-user-agreement.accept.error": "An error occurred accepting the End User Agreement", @@ -4039,6 +4083,12 @@ "profile.breadcrumbs": "Update Profile", + "profile.card.accessibility.content": "Accessibility settings can be configured on the accessibility settings page.", + + "profile.card.accessibility.header": "Accessibility", + + "profile.card.accessibility.link": "Go to Accessibility Settings Page", + "profile.card.identify": "Identify", "profile.card.security": "Security", diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 94c7bd9f7d..3ae5918558 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -4,6 +4,7 @@ import { Type, } from '@angular/core'; +import { AccessibilitySettingsConfig } from '../app/accessibility/accessibility-settings.config'; import { AdminNotifyMetricsRow } from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model'; import { HALDataService } from '../app/core/data/base/hal-data-service.interface'; import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; @@ -36,7 +37,6 @@ import { SuggestionConfig } from './suggestion-config.interfaces'; import { ThemeConfig } from './theme.config'; import { UIServerConfig } from './ui-server-config.interface'; - interface AppConfig extends Config { ui: UIServerConfig; rest: ServerConfig; @@ -70,6 +70,7 @@ interface AppConfig extends Config { liveRegion: LiveRegionConfig; matomo?: MatomoConfig; geospatialMapViewer: GeospatialMapConfig; + accessibility: AccessibilitySettingsConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index ad54fd3f43..1d5d86d628 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -1,3 +1,4 @@ +import { AccessibilitySettingsConfig } from '../app/accessibility/accessibility-settings.config'; import { AdminNotifyMetricsRow } from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model'; import { RestRequestMethod } from '../app/core/data/rest-request-method'; import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; @@ -629,4 +630,9 @@ export class DefaultAppConfig implements AppConfig { lng: 28.979530, }, }; + + // Accessibility settings configuration, used by the AccessibilitySettingsService + accessibility: AccessibilitySettingsConfig = { + cookieExpirationDuration: 7, + }; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index ee59d09d73..500ec0f6d4 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -473,4 +473,8 @@ export const environment: BuildConfig = { lng: 28.979530, }, }, + + accessibility: { + cookieExpirationDuration: 7, + }, }; diff --git a/src/themes/custom/app/profile-page/profile-page.component.ts b/src/themes/custom/app/profile-page/profile-page.component.ts index 9e3f5d3040..1468684c48 100644 --- a/src/themes/custom/app/profile-page/profile-page.component.ts +++ b/src/themes/custom/app/profile-page/profile-page.component.ts @@ -3,6 +3,7 @@ import { NgTemplateOutlet, } from '@angular/common'; import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { SuggestionsNotificationComponent } from '../../../../app/notifications/suggestions/notification/suggestions-notification.component'; @@ -10,6 +11,7 @@ import { ProfilePageComponent as BaseComponent } from '../../../../app/profile-p import { ThemedProfilePageMetadataFormComponent } from '../../../../app/profile-page/profile-page-metadata-form/themed-profile-page-metadata-form.component'; import { ProfilePageResearcherFormComponent } from '../../../../app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component'; import { ProfilePageSecurityFormComponent } from '../../../../app/profile-page/profile-page-security-form/profile-page-security-form.component'; +import { AlertComponent } from '../../../../app/shared/alert/alert.component'; import { ErrorComponent } from '../../../../app/shared/error/error.component'; import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component'; import { PaginationComponent } from '../../../../app/shared/pagination/pagination.component'; @@ -34,6 +36,8 @@ import { VarDirective } from '../../../../app/shared/utils/var.directive'; PaginationComponent, ThemedLoadingComponent, ErrorComponent, + RouterModule, + AlertComponent, ], }) /**