mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Merge pull request #3613 from atmire/accessibility-settings-main
Accessibility settings page
This commit is contained in:
@@ -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
|
||||
|
@@ -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'
|
||||
|
@@ -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();
|
||||
|
@@ -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);
|
||||
|
11
src/app/accessibility/accessibility-settings.config.ts
Normal file
11
src/app/accessibility/accessibility-settings.config.ts
Normal file
@@ -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;
|
||||
}
|
419
src/app/accessibility/accessibility-settings.service.spec.ts
Normal file
419
src/app/accessibility/accessibility-settings.service.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
45
src/app/accessibility/accessibility-settings.service.stub.ts
Normal file
45
src/app/accessibility/accessibility-settings.service.stub.ts
Normal file
@@ -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);
|
||||
}
|
381
src/app/accessibility/accessibility-settings.service.ts
Normal file
381
src/app/accessibility/accessibility-settings.service.ts
Normal file
@@ -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<FullAccessibilitySettings>;
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<number> {
|
||||
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<AccessibilitySettings> {
|
||||
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<AccessibilitySettings> {
|
||||
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');
|
||||
}
|
@@ -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<EPerson> {
|
||||
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.
|
||||
*/
|
||||
|
@@ -66,6 +66,10 @@
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<a class="btn text-white"
|
||||
routerLink="info/accessibility" role="link" tabindex="0">{{ 'footer.link.accessibility' | translate }}</a>
|
||||
</li>
|
||||
@if (showPrivacyPolicy) {
|
||||
<li>
|
||||
<a class="btn text-white"
|
||||
|
@@ -0,0 +1,96 @@
|
||||
<div class="container">
|
||||
<h2>{{ 'info.accessibility-settings.title' | translate }}</h2>
|
||||
|
||||
<form>
|
||||
<div class="form-group row">
|
||||
<label [for]="'disableNotificationTimeOutInput'" class="col-sm-4 col-form-label">
|
||||
{{ 'info.accessibility-settings.disableNotificationTimeOut.label' | translate }}
|
||||
</label>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<ui-switch [id]="'disableNotificationTimeOutInput'"
|
||||
[(ngModel)]="formValues.notificationTimeOutEnabled"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
></ui-switch>
|
||||
</div>
|
||||
|
||||
<div *dsContextHelp="{
|
||||
content: 'info.accessibility-settings.disableNotificationTimeOut.hint',
|
||||
id: 'disableNotificationTimeOutHelp',
|
||||
iconPlacement: 'right',
|
||||
tooltipPlacement: 'left'
|
||||
}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label [for]="'notificationTimeOutInput'" class="col-sm-4 col-form-label">
|
||||
{{ 'info.accessibility-settings.notificationTimeOut.label' | translate }}
|
||||
</label>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<input [type]="'number'" [id]="'notificationTimeOutInput'" class="form-control"
|
||||
[ngClass]="{'is-invalid': !settingsService.isValid('notificationTimeOut', formValues)}"
|
||||
[min]="1"
|
||||
[readOnly]="!formValues.notificationTimeOutEnabled"
|
||||
[(ngModel)]="formValues.notificationTimeOut" [ngModelOptions]="{ standalone: true }"
|
||||
[attr.aria-describedby]="'notificationTimeOutHint'">
|
||||
<div class="invalid-feedback" [ngClass]="{ 'd-block': !settingsService.isValid('notificationTimeOut', formValues) }">
|
||||
{{ 'info.accessibility-settings.notificationTimeOut.invalid' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-sm-1" *dsContextHelp="{
|
||||
content: 'info.accessibility-settings.notificationTimeOut.hint',
|
||||
id: 'notificationTimeOutHelp',
|
||||
iconPlacement: 'right',
|
||||
tooltipPlacement: 'left'
|
||||
}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label [for]="'liveRegionTimeOutInput'" class="col-sm-4 col-form-label">
|
||||
{{ 'info.accessibility-settings.liveRegionTimeOut.label' | translate }}
|
||||
</label>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<input [type]="'number'" [id]="'liveRegionTimeOutInput'" class="form-control"
|
||||
[ngClass]="{'is-invalid': !settingsService.isValid('liveRegionTimeOut', formValues)}"
|
||||
[min]="1"
|
||||
[(ngModel)]="formValues.liveRegionTimeOut" [ngModelOptions]="{ standalone: true }"
|
||||
[attr.aria-describedby]="'liveRegionTimeOutHint'">
|
||||
<div class="invalid-feedback" [ngClass]="{ 'd-block': !settingsService.isValid('liveRegionTimeOut', formValues) }">
|
||||
{{ 'info.accessibility-settings.liveRegionTimeOut.invalid' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-sm-1" *dsContextHelp="{
|
||||
content: 'info.accessibility-settings.liveRegionTimeOut.hint',
|
||||
id: 'liveRegionTimeOutHelp',
|
||||
iconPlacement: 'right',
|
||||
tooltipPlacement: 'left'
|
||||
}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="group">
|
||||
<button type="submit" (click)="saveSettings()" class="btn btn-primary mr-2">
|
||||
{{ 'info.accessibility-settings.submit' | translate }}
|
||||
</button>
|
||||
<button (click)="resetSettings()" class="btn btn-warning">
|
||||
{{ 'info.accessibility-settings.reset' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@if ((isAuthenticated | async) === false && (cookieIsAccepted | async) === false) {
|
||||
<div class="mt-2">
|
||||
<ds-alert [type]="AlertType.Warning">{{ 'info.accessibility-settings.cookie-warning' | translate }}</ds-alert>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
@@ -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<AccessibilitySettingsComponent>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
@@ -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<boolean> = new BehaviorSubject(false);
|
||||
cookieIsAccepted: BehaviorSubject<boolean> = 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@@ -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,
|
||||
|
@@ -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}`;
|
||||
}
|
||||
|
@@ -35,6 +35,15 @@
|
||||
<div class="col-12 text-end pe-0">
|
||||
<button class="btn btn-primary" (click)="updateProfile()"><i class="fas fa-edit"></i> {{'profile.form.submit' | translate}}</button>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">{{'profile.card.accessibility.header' | translate}}</div>
|
||||
<div class="card-body">
|
||||
<ds-alert class="mb-4" [type]="'alert-info'">{{'profile.card.accessibility.content' | translate}}</ds-alert>
|
||||
<a [routerLink]="'/info/accessibility'">{{'profile.card.accessibility.link' | translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ((groupsRD$ | async); as groupsRD;) {
|
||||
<ng-container *ngTemplateOutlet="groupsRD?.isLoading ? loader : content"></ng-container>
|
||||
<ng-template #content>
|
||||
|
@@ -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 },
|
||||
|
@@ -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,
|
||||
})
|
||||
|
@@ -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],
|
||||
})
|
||||
|
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
9
src/app/shared/cookies/orejime.service.stub.ts
Normal file
9
src/app/shared/cookies/orejime.service.stub.ts
Normal file
@@ -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({}));
|
||||
}
|
@@ -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: {
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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<number> {
|
||||
return this.accessibilitySettingsService.getAsNumber(
|
||||
'liveRegionTimeOut',
|
||||
this.getMessageTimeOutMs(),
|
||||
).pipe(map(timeOut => Math.max(timeOut, MIN_MESSAGE_DURATION)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current message timeOut duration in milliseconds
|
||||
*/
|
||||
|
@@ -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
|
||||
|
@@ -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<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
constructor(
|
||||
private service: NotificationsService,
|
||||
private store: Store<AppState>,
|
||||
private cdr: ChangeDetectorRef,
|
||||
protected service: NotificationsService,
|
||||
protected store: Store<AppState>,
|
||||
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
|
||||
|
@@ -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, {
|
||||
|
@@ -60,6 +60,10 @@ export class AuthServiceStub {
|
||||
return observableOf(EPersonMock);
|
||||
}
|
||||
|
||||
getAuthenticatedUserFromStoreIfAuthenticated(): Observable<EPerson> {
|
||||
return observableOf(EPersonMock);
|
||||
}
|
||||
|
||||
public buildAuthHeader(token?: AuthTokenInfo): string {
|
||||
return `Bearer ${token ? token.accessToken : ''}`;
|
||||
}
|
||||
|
@@ -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({}),
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
@@ -473,4 +473,8 @@ export const environment: BuildConfig = {
|
||||
lng: 28.979530,
|
||||
},
|
||||
},
|
||||
|
||||
accessibility: {
|
||||
cookieExpirationDuration: 7,
|
||||
},
|
||||
};
|
||||
|
@@ -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,
|
||||
],
|
||||
})
|
||||
/**
|
||||
|
Reference in New Issue
Block a user