diff --git a/src/app/app.effects.ts b/src/app/app.effects.ts index 61b76808388..65375106727 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -1,4 +1,3 @@ - import { NavbarEffects } from './navbar/navbar.effects'; import { RelationshipEffects } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects'; import { MenuEffects } from './shared/menu/menu.effects'; diff --git a/src/app/core/cookies/orejime-configuration.ts b/src/app/core/cookies/orejime-configuration.ts index a5b1b6fa01b..0adaadbb925 100644 --- a/src/app/core/cookies/orejime-configuration.ts +++ b/src/app/core/cookies/orejime-configuration.ts @@ -1,14 +1,16 @@ +import { LANG_COOKIE } from '@dspace/core/locale/locale.service'; +import { NativeWindowRef } from '@dspace/core/services/window.service'; + +import { + CAPTCHA_COOKIE, + CAPTCHA_NAME, +} from '../../core/google-recaptcha/google-recaptcha.service'; +import { PINNED_MENU_COOKIE } from '../../shared/menu/menu.service'; import { IMPERSONATING_COOKIE, REDIRECT_COOKIE, } from '../auth/auth.service'; import { TOKENITEM } from '../auth/models/auth-token-info.model'; -import { - CAPTCHA_COOKIE, - CAPTCHA_NAME, -} from '../google-recaptcha/google-recaptcha.service'; -import { LANG_COOKIE } from '../locale/locale.service'; -import { NativeWindowRef } from '../services/window.service'; import { ACCESSIBILITY_COOKIE } from './accessibility-cookie'; /** @@ -230,6 +232,12 @@ export function getOrejimeConfiguration(_window: NativeWindowRef): any { cookies: [ACCESSIBILITY_COOKIE], onlyOnce: false, }, + { + name: 'menu-state', + purposes: ['functional'], + required: false, + cookies: [PINNED_MENU_COOKIE], + }, ], }; } diff --git a/src/app/init.service.ts b/src/app/init.service.ts index 7838a21706d..f473e471707 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -227,4 +227,9 @@ export abstract class InitService { find((b: boolean) => b === false), ); } + + protected configureMenuCollapsedState(): void { + this.menuService.syncMenuCollapsedState(); + } + } diff --git a/src/app/shared/host-window.service.spec.ts b/src/app/shared/host-window.service.spec.ts index e961fc0c5b4..e6afb53d598 100644 --- a/src/app/shared/host-window.service.spec.ts +++ b/src/app/shared/host-window.service.spec.ts @@ -28,7 +28,7 @@ describe('HostWindowService', () => { beforeEach(() => { const _initialState: Partial = { hostWindow: { width: 1600, height: 770 } }; store = createMockStore({ initialState: _initialState }); - service = new HostWindowService(store, new CSSVariableServiceStub() as any); + service = new HostWindowService(store, new CSSVariableServiceStub() as any, 'browser'); }); it('isXs() should return false with width = 1600', () => { @@ -64,7 +64,7 @@ describe('HostWindowService', () => { beforeEach(() => { const _initialState: Partial = { hostWindow: { width: 1100, height: 770 } }; store = createMockStore({ initialState: _initialState }); - service = new HostWindowService(store, new CSSVariableServiceStub() as any); + service = new HostWindowService(store, new CSSVariableServiceStub() as any, 'browser'); }); it('isXs() should return false with width = 1100', () => { @@ -100,7 +100,7 @@ describe('HostWindowService', () => { beforeEach(() => { const _initialState = { hostWindow: { width: 800, height: 770 } }; store = createMockStore({ initialState: _initialState }); - service = new HostWindowService(store, new CSSVariableServiceStub() as any); + service = new HostWindowService(store, new CSSVariableServiceStub() as any, 'browser'); }); it('isXs() should return false with width = 800', () => { @@ -136,7 +136,7 @@ describe('HostWindowService', () => { beforeEach(() => { const _initialState = { hostWindow: { width: 600, height: 770 } }; store = createMockStore({ initialState: _initialState }); - service = new HostWindowService(store, new CSSVariableServiceStub() as any); + service = new HostWindowService(store, new CSSVariableServiceStub() as any, 'browser'); }); it('isXs() should return false with width = 600', () => { @@ -172,7 +172,7 @@ describe('HostWindowService', () => { beforeEach(() => { const _initialState = { hostWindow: { width: 400, height: 770 } }; store = createMockStore({ initialState: _initialState }); - service = new HostWindowService(store, new CSSVariableServiceStub() as any); + service = new HostWindowService(store, new CSSVariableServiceStub() as any, 'browser'); }); it('isXs() should return true with width = 400', () => { @@ -206,7 +206,8 @@ describe('HostWindowService', () => { describe('widthCategory', () => { beforeEach(() => { - service = new HostWindowService({} as Store, new CSSVariableServiceStub() as any); + service = new HostWindowService({} as Store, new CSSVariableServiceStub() as any, + 'browser'); }); it('should call getWithObs to get the current width', () => { diff --git a/src/app/shared/host-window.service.ts b/src/app/shared/host-window.service.ts index 7db690eb1ee..00749238e0d 100644 --- a/src/app/shared/host-window.service.ts +++ b/src/app/shared/host-window.service.ts @@ -1,4 +1,9 @@ -import { Injectable } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { + Inject, + Injectable, + PLATFORM_ID, +} from '@angular/core'; import { maxMobileWidth, WidthCategory, @@ -12,6 +17,7 @@ import { import { combineLatest as observableCombineLatest, Observable, + of, } from 'rxjs'; import { distinctUntilChanged, @@ -33,6 +39,7 @@ export class HostWindowService { constructor( private store: Store, private variableService: CSSVariableService, + @Inject(PLATFORM_ID) private platformId: any, ) { /* See _exposed_variables.scss */ variableService.getAllVariables() @@ -52,6 +59,11 @@ export class HostWindowService { } get widthCategory(): Observable { + if (isPlatformServer(this.platformId)) { + // During SSR we won't know the viewport width -- assume we're rendering for desktop + return of(WidthCategory.XL); + } + return this.getWidthObs().pipe( map((width: number) => { if (width < this.breakPoints.SM_MIN) { diff --git a/src/app/shared/menu/menu.component.spec.ts b/src/app/shared/menu/menu.component.spec.ts index d60850a2122..1759a8243c1 100644 --- a/src/app/shared/menu/menu.component.spec.ts +++ b/src/app/shared/menu/menu.component.spec.ts @@ -15,9 +15,9 @@ import { import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { authReducer } from '@dspace/core/auth/auth.reducer'; -import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { CookieService } from '@dspace/core/cookies/cookie.service'; import { Item } from '@dspace/core/shared/item.model'; +import { CookieServiceMock } from '@dspace/core/testing/cookie.service.mock'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { Store, @@ -37,6 +37,8 @@ import { AppState, storeModuleConfig, } from '../../app.reducer'; +import { authReducer } from '../../core/auth/auth.reducer'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { getMockThemeService } from '../theme-support/test/theme-service.mock'; import { ThemeService } from '../theme-support/theme.service'; import { MenuComponent } from './menu.component'; @@ -148,6 +150,7 @@ describe('MenuComponent', () => { Injector, { provide: ThemeService, useValue: getMockThemeService() }, MenuService, + { provide: CookieService, useValue: CookieServiceMock }, provideMockStore({ initialState }), { provide: AuthorizationDataService, useValue: authorizationService }, { provide: ActivatedRoute, useValue: routeStub }, diff --git a/src/app/shared/menu/menu.effects.spec.ts b/src/app/shared/menu/menu.effects.spec.ts index 642768d7de2..f3faf687477 100644 --- a/src/app/shared/menu/menu.effects.spec.ts +++ b/src/app/shared/menu/menu.effects.spec.ts @@ -13,6 +13,8 @@ import { Observable } from 'rxjs'; import { StoreAction } from '../../store.actions'; import { ReinitMenuAction } from './menu.actions'; import { MenuEffects } from './menu.effects'; +import { MenuService } from './menu.service'; +import { MenuServiceStub } from './menu-service.stub'; describe('MenuEffects', () => { let menuEffects: MenuEffects; @@ -22,6 +24,7 @@ describe('MenuEffects', () => { providers: [ MenuEffects, provideMockActions(() => actions), + { provide: MenuService, useValue: new MenuServiceStub() }, ], }); })); diff --git a/src/app/shared/menu/menu.effects.ts b/src/app/shared/menu/menu.effects.ts index 0a01323f2bd..5f9b5d13138 100644 --- a/src/app/shared/menu/menu.effects.ts +++ b/src/app/shared/menu/menu.effects.ts @@ -5,9 +5,19 @@ import { createEffect, ofType, } from '@ngrx/effects'; -import { map } from 'rxjs/operators'; +import { + map, + tap, +} from 'rxjs/operators'; -import { ReinitMenuAction } from './menu.actions'; +import { + CollapseMenuAction, + ExpandMenuAction, + MenuActionTypes, + ReinitMenuAction, + ToggleMenuAction, +} from './menu.actions'; +import { MenuService } from './menu.service'; @Injectable() export class MenuEffects { @@ -21,7 +31,25 @@ export class MenuEffects { map(() => new ReinitMenuAction()), )); - constructor(private actions$: Actions) { + menuCollapsedStateToggle$ = createEffect(() => this.actions$.pipe( + ofType(MenuActionTypes.TOGGLE_MENU), + tap((action: ToggleMenuAction) => this.menuService.toggleMenuCollapsedState(action.menuID)), + ), { dispatch: false }); + + menuCollapsedStateCollapse$ = createEffect(() => this.actions$.pipe( + ofType(MenuActionTypes.COLLAPSE_MENU), + tap((action: CollapseMenuAction) => this.menuService.setMenuCollapsedState(action.menuID, true)), + ), { dispatch: false }); + + menuCollapsedStateExpand$ = createEffect(() => this.actions$.pipe( + ofType(MenuActionTypes.EXPAND_MENU), + tap((action: ExpandMenuAction) => this.menuService.setMenuCollapsedState(action.menuID, false)), + ), { dispatch: false }); + + constructor( + private actions$: Actions, + private menuService: MenuService, + ) { } } diff --git a/src/app/shared/menu/menu.service.spec.ts b/src/app/shared/menu/menu.service.spec.ts index 5308a04d3cb..324d1064424 100644 --- a/src/app/shared/menu/menu.service.spec.ts +++ b/src/app/shared/menu/menu.service.spec.ts @@ -3,6 +3,8 @@ import { waitForAsync, } from '@angular/core/testing'; import { NavigationEnd } from '@angular/router'; +import { CookieService } from '@dspace/core/cookies/cookie.service'; +import { CookieServiceMock } from '@dspace/core/testing/cookie.service.mock'; import { Store, StoreModule, @@ -27,7 +29,10 @@ import { ToggleMenuAction, } from './menu.actions'; import { menusReducer } from './menu.reducer'; -import { MenuService } from './menu.service'; +import { + MenuService, + PINNED_MENU_COOKIE, +} from './menu.service'; import { MenuID } from './menu-id.model'; import { LinkMenuItemModel } from './menu-item/models/link.model'; import { MenuItemType } from './menu-item-type.model'; @@ -50,6 +55,7 @@ describe('MenuService', () => { let alreadyPresentMenuSection: MenuSection; let route; let router; + let cookieService; function init() { @@ -181,6 +187,8 @@ describe('MenuService', () => { router = { events: of(new NavigationEnd(1, 'test-url', 'test-url')), }; + + cookieService = new CookieServiceMock(); } beforeEach(waitForAsync(() => { @@ -192,13 +200,14 @@ describe('MenuService', () => { providers: [ provideMockStore({ initialState }), { provide: MenuService, useValue: service }, + { provide: CookieService, useClass: CookieServiceMock }, ], }).compileComponents(); })); beforeEach(() => { store = TestBed.inject(Store); - service = new MenuService(store, route, router); + service = new MenuService(store, route, router, cookieService); spyOn(store, 'dispatch'); }); @@ -525,6 +534,39 @@ describe('MenuService', () => { }); }); + describe('toggleMenuCollapsedState', () => { + it('should update the collapsed state of the given menu in the cookie', () => { + service.toggleMenuCollapsedState(MenuID.ADMIN); + expect(cookieService.get(PINNED_MENU_COOKIE)[MenuID.ADMIN]).toEqual(false); + }); + }); + + describe('syncMenuCollapsedState', () => { + it('should call expandMenu when a menu is collapsed in the store but expanded in the cookie', () => { + spyOn(service, 'expandMenu'); + let cookieWithExpandedAdmin: object = { + [MenuID.ADMIN]: false, + [MenuID.PUBLIC]: false, + [MenuID.DSO_EDIT]: false, + }; + cookieService.set(PINNED_MENU_COOKIE, cookieWithExpandedAdmin); + service.syncMenuCollapsedState(); + expect(service.expandMenu).toHaveBeenCalledWith(MenuID.ADMIN); + }); + + it('should call collapseMenu when a menu is expanded in the store but collapsed in the cookie', () => { + spyOn(service, 'collapseMenu'); + let cookieWithCollapsedAdmin: object = { + [MenuID.ADMIN]: true, + [MenuID.PUBLIC]: false, + [MenuID.DSO_EDIT]: false, + }; + cookieService.set(PINNED_MENU_COOKIE, cookieWithCollapsedAdmin); + service.syncMenuCollapsedState(); + expect(service.collapseMenu).toHaveBeenCalledWith(MenuID.ADMIN); + }); + }); + describe('toggleActiveSection', () => { it('should dispatch an ToggleActiveMenuSectionAction with the correct arguments', () => { service.toggleActiveSection(MenuID.ADMIN, 'fakeID'); diff --git a/src/app/shared/menu/menu.service.ts b/src/app/shared/menu/menu.service.ts index f91c5958d16..17475f49f0c 100644 --- a/src/app/shared/menu/menu.service.ts +++ b/src/app/shared/menu/menu.service.ts @@ -3,6 +3,7 @@ import { ActivatedRoute, Router, } from '@angular/router'; +import { CookieService } from '@dspace/core/cookies/cookie.service'; import { compareArraysUsingIds } from '@dspace/core/utilities/item-relationships-utils'; import { hasNoValue, @@ -79,6 +80,14 @@ const getSubSectionsFromSectionSelector = (id: string): MemoizedSelector(id, menuSectionIndexStateSelector); }; +export const PINNED_MENU_COOKIE = 'dsMenuCollapsedState'; +export const PINNED_MENU_COOKIE_DEFAULT: PinnedMenuCookie = { + [MenuID.ADMIN]: true, + [MenuID.PUBLIC]: false, + [MenuID.DSO_EDIT]: false, +}; +export const PINNED_MENU_COOKIE_EXPIRES = 10000; + @Injectable({ providedIn: 'root' }) export class MenuService { @@ -86,6 +95,7 @@ export class MenuService { protected store: Store, protected route: ActivatedRoute, protected router: Router, + protected cookieService: CookieService, ) { } @@ -317,6 +327,60 @@ export class MenuService { this.store.dispatch(new HideMenuAction(menuID)); } + /** + * Returns whether the menu with {@linkcode menuID} is currently collapsed. + */ + isCollapsed(menuID: MenuID): boolean { + const cookie: PinnedMenuCookie | undefined = this.cookieService.get(PINNED_MENU_COOKIE); + return cookie?.[menuID]; + } + + /** + * Collapses the menu with {@linkcode menuID} if expanded. Expands it if collapsed. + */ + toggleMenuCollapsedState(menuID: MenuID): void { + let cookie: PinnedMenuCookie | undefined = this.cookieService.get(PINNED_MENU_COOKIE); + if (!hasValue(cookie)) { + cookie = PINNED_MENU_COOKIE_DEFAULT; + } + cookie[menuID] = !cookie[menuID]; + this.cookieService.set(PINNED_MENU_COOKIE, cookie, { expires: PINNED_MENU_COOKIE_EXPIRES }); + } + + /** + * Collapses or expands the menu with {@linkcode menuID} based on {@linkcode collapsed}. + */ + setMenuCollapsedState(menuID: MenuID, collapsed: boolean): void { + let cookie: PinnedMenuCookie | undefined = this.cookieService.get(PINNED_MENU_COOKIE); + if (hasValue(cookie)) { + if (cookie[menuID] === collapsed) { + // Do not save the cookie if it's unchanged + return; + } + } else { + cookie = PINNED_MENU_COOKIE_DEFAULT; + } + cookie[menuID] = collapsed; + this.cookieService.set(PINNED_MENU_COOKIE, cookie, { expires: PINNED_MENU_COOKIE_EXPIRES }); + } + + /** + * Expands or collapses the navbar based on the {@link PINNED_MENU_COOKIE} cookie value. + */ + syncMenuCollapsedState(): void { + const cookie: PinnedMenuCookie | undefined = this.cookieService.get(PINNED_MENU_COOKIE); + if (!hasValue(cookie)) { + return; + } + for (const menuID in cookie) { + if (cookie?.[menuID]) { + this.collapseMenu(menuID as MenuID); + } else { + this.expandMenu(menuID as MenuID); + } + } + } + /** * Hide a given menu section * @param {MenuID} menuID The ID of the menu @@ -374,3 +438,7 @@ export class MenuService { } } + +type PinnedMenuCookie = { + [key in MenuID]: boolean; +}; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c0b620d24d7..81251e678aa 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1683,6 +1683,10 @@ "cookies.consent.app.description.matomo": "Allows us to track statistical data", + "cookies.consent.app.title.menu-state": "Menu State", + + "cookies.consent.app.description.menu-state": "Required to save menu pins", + "cookies.consent.purpose.functional": "Functional", "cookies.consent.purpose.statistical": "Statistical", diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 524229d8bb5..a7cd7855147 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -137,6 +137,8 @@ export class BrowserInitService extends InitService { await lastValueFrom(this.authenticationReady$()); this.menuProviderService.initPersistentMenus(false); + this.configureMenuCollapsedState(); + return true; }; } diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index 71012c40cdf..31c4d957feb 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -87,6 +87,8 @@ export class ServerInitService extends InitService { await lastValueFrom(this.authenticationReady$()); this.menuProviderService.initPersistentMenus(true); + this.configureMenuCollapsedState(); + return true; }; } @@ -127,4 +129,5 @@ export class ServerInitService extends InitService { this.transferState.set(APP_CONFIG_STATE, environment as AppConfig); } } + }