import { Injectable } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { AppState, keySelector } from '../../app.reducer'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; import { ActivateMenuSectionAction, AddMenuSectionAction, CollapseMenuAction, CollapseMenuPreviewAction, DeactivateMenuSectionAction, ExpandMenuAction, ExpandMenuPreviewAction, HideMenuAction, HideMenuSectionAction, RemoveMenuSectionAction, ShowMenuAction, ShowMenuSectionAction, ToggleActiveMenuSectionAction, ToggleMenuAction, } from './menu.actions'; import { hasNoValue, hasValue, hasValueOperator, isNotEmpty, isEmpty } from '../empty.util'; import { MenuState } from './menu-state.model'; import { MenuSections } from './menu-sections.model'; import { MenuSection } from './menu-section.model'; import { MenuID } from './menu-id.model'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { compareArraysUsingIds } from '../../item-page/simple/item-types/shared/item-relationships-utils'; export function menuKeySelector(key: string, selector): MemoizedSelector { return createSelector(selector, (state) => { if (hasValue(state)) { return state[key]; } else { return undefined; } }); } const menusStateSelector = (state) => state.menus; const menuByIDSelector = (menuID: MenuID): MemoizedSelector => { return keySelector(menuID, menusStateSelector); }; const menuSectionStateSelector = (state: MenuState) => hasValue(state) ? state.sections : {}; const menuSectionByIDSelector = (id: string): MemoizedSelector => { return menuKeySelector(id, menuSectionStateSelector); }; const menuSectionIndexStateSelector = (state: MenuState) => state.sectionToSubsectionIndex; const getSubSectionsFromSectionSelector = (id: string): MemoizedSelector => { return menuKeySelector(id, menuSectionIndexStateSelector); }; @Injectable() export class MenuService { constructor( protected store: Store, protected route: ActivatedRoute, protected router: Router, ) { } /** * Retrieve a menu's state by its ID * @param {MenuID} id ID of the requested Menu * @returns {Observable} Observable that emits the current state of the requested Menu */ getMenu(id: MenuID): Observable { return this.store.pipe(select(menuByIDSelector(id))); } /** * Retrieve all top level sections of a certain menu * @param {MenuID} menuID ID of the Menu * @param {boolean} mustBeVisible True if you only want to request visible sections, false if you want to request all top level sections * @returns {Observable} Observable that emits a list of MenuSections that are top sections of the given menu */ getMenuTopSections(menuID: MenuID, mustBeVisible = true): Observable { return this.store.pipe( select(menuByIDSelector(menuID)), select(menuSectionStateSelector), map((sections: MenuSections) => Object.values(sections)), distinctUntilChanged(compareArraysUsingIds()), map((sections: MenuSection[]) => { return sections .filter((section: MenuSection) => hasNoValue(section.parentID)) .filter((section: MenuSection) => !mustBeVisible || section.visible); } ) ); } /** * Retrieve all sub level sections of a certain top section in a given menu * @param {MenuID} menuID The ID of the menu * @param {string} parentID The ID of the parent section * @param {boolean} mustBeVisible True if you only want to request visible sections, false if you want to request all sections * @returns {Observable} Observable that emits a list of MenuSections that are sub sections of the given menu and parent section */ getSubSectionsByParentID(menuID: MenuID, parentID: string, mustBeVisible = true): Observable { return this.store.pipe( select(menuByIDSelector(menuID)), select(getSubSectionsFromSectionSelector(parentID)), map((ids: string[]) => isNotEmpty(ids) ? ids : []), switchMap((ids: string[]) => observableCombineLatest(ids.map((id: string) => this.getMenuSection(menuID, id))) ), map((sections: MenuSection[]) => sections.filter((section: MenuSection) => hasValue(section) && (!mustBeVisible || section.visible))) ); } /** * Check if the a menu's top level section has subsections * @param {MenuID} menuID The ID of the Menu * @param {string} parentID The ID of the top level parent section * @returns {Observable} Observable that emits true when the given parent section has sub sections, false if the given parent section does not have any sub sections */ hasSubSections(menuID: MenuID, parentID: string): Observable { return this.store.pipe( select(menuByIDSelector(menuID)), select(getSubSectionsFromSectionSelector(parentID)), map((ids: string[]) => isNotEmpty(ids)) ); } /** * Retrieve a specific menu section by its menu ID and section ID * @param {MenuID} menuID The ID of the menu the section resides in * @param {string} sectionId The ID of the requested section * @returns {Observable} Observable that emits the found MenuSection */ getMenuSection(menuID: MenuID, sectionId: string): Observable { return this.store.pipe( select(menuByIDSelector(menuID)), select(menuSectionByIDSelector(sectionId)), ); } /** * Retrieve menu sections that shouldn't persist on route change * @param menuID The ID of the menu the sections reside in */ getNonPersistentMenuSections(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( map((state: MenuState) => Object.values(state.sections).filter((section: MenuSection) => !section.shouldPersistOnRouteChange)) ); } /** * Add a new section to the store * @param {MenuID} menuID The menu to which the new section is to be added * @param {MenuSection} section The section to be added */ addSection(menuID: MenuID, section: MenuSection) { this.store.dispatch(new AddMenuSectionAction(menuID, section)); } /** * Remove a section from the store * @param {MenuID} menuID The menu from which the section is to be removed * @param {string} sectionID The ID of the section that should be removed */ removeSection(menuID: MenuID, sectionID: string) { this.store.dispatch(new RemoveMenuSectionAction(menuID, sectionID)); } /** * Check if a given menu is collapsed * @param {MenuID} menuID The ID of the menu that is to be checked * @returns {Observable} Emits true if the given menu is collapsed, emits falls when it's expanded */ isMenuCollapsed(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( map((state: MenuState) => hasValue(state) ? state.collapsed : undefined) ); } /** * Check if a given menu's preview is collapsed * @param {MenuID} menuID The ID of the menu that is to be checked * @returns {Observable} Emits true if the given menu's preview is collapsed, emits falls when it's expanded */ isMenuPreviewCollapsed(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( map((state: MenuState) => hasValue(state) ? state.previewCollapsed : undefined) ); } /** * Check if a given menu is visible and has visible top-level (!) sections * @param {MenuID} menuID The ID of the menu that is to be checked * @returns {Observable} Emits true if the given menu is * visible and has visible sections, emits false when it's hidden */ isMenuVisibleWithVisibleSections(menuID: MenuID): Observable { return observableCombineLatest([this.isMenuVisible(menuID), this.menuHasVisibleSections(menuID)]).pipe( map(([menuVisible, visibleSections]) => menuVisible && visibleSections) ); } /** * Check if a given menu is visible * @param {MenuID} menuID The ID of the menu that is to be checked * @returns {Observable} Emits true if the given menu is visible, emits falls when it's hidden */ isMenuVisible(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( map((state: MenuState) => hasValue(state) ? state.visible : undefined) ); } /** * Check if a menu has at least one top-level (!) section that is visible. * @param {MenuID} menuID The ID of the menu that is to be checked * @returns {Observable} Emits true if the given menu has visible sections, emits false otherwise */ menuHasVisibleSections(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( map((state: MenuState) => hasValue(state) ? Object.values(state.sections) .some(section => section.visible && section.parentID === undefined) : undefined) ); } /** * Expands a given menu * @param {MenuID} menuID The ID of the menu */ expandMenu(menuID: MenuID): void { this.store.dispatch(new ExpandMenuAction(menuID)); } /** * Collapses a given menu * @param {MenuID} menuID The ID of the menu */ collapseMenu(menuID: MenuID): void { this.store.dispatch(new CollapseMenuAction(menuID)); } /** * Expands a given menu's preview * @param {MenuID} menuID The ID of the menu */ expandMenuPreview(menuID: MenuID): void { this.store.dispatch(new ExpandMenuPreviewAction(menuID)); } /** * Collapses a given menu's preview * @param {MenuID} menuID The ID of the menu */ collapseMenuPreview(menuID: MenuID): void { this.store.dispatch(new CollapseMenuPreviewAction(menuID)); } /** * Collapse a given menu when it's currently expanded or expand it when it's currently collapsed * @param {MenuID} menuID The ID of the menu */ toggleMenu(menuID: MenuID): void { this.store.dispatch(new ToggleMenuAction(menuID)); } /** * Show a given menu * @param {MenuID} menuID The ID of the menu */ showMenu(menuID: MenuID): void { this.store.dispatch(new ShowMenuAction(menuID)); } /** * Show a given menu section * @param {MenuID} menuID The ID of the menu * @param id The ID of the section */ showMenuSection(menuID: MenuID, id: string): void { this.store.dispatch(new ShowMenuSectionAction(menuID, id)); } /** * Hide a given menu * @param {MenuID} menuID The ID of the menu */ hideMenu(menuID: MenuID): void { this.store.dispatch(new HideMenuAction(menuID)); } /** * Hide a given menu section * @param {MenuID} menuID The ID of the menu * @param id The ID of the section */ hideMenuSection(menuID: MenuID, id: string): void { this.store.dispatch(new HideMenuSectionAction(menuID, id)); } /** * Activate a given menu section when it's currently inactive or deactivate it when it's currently active * @param {MenuID} menuID The ID of the menu * @param {string} id The ID of the section */ toggleActiveSection(menuID: MenuID, id: string): void { this.store.dispatch(new ToggleActiveMenuSectionAction(menuID, id)); } /** * Activate a given menu section * @param {MenuID} menuID The ID of the menu * @param {string} id The ID of the section */ activateSection(menuID: MenuID, id: string): void { this.store.dispatch(new ActivateMenuSectionAction(menuID, id)); } /** * Deactivate a given menu section * @param {MenuID} menuID The ID of the menu * @param {string} id The ID of the section */ deactivateSection(menuID: MenuID, id: string): void { this.store.dispatch(new DeactivateMenuSectionAction(menuID, id)); } /** * Check whether a given section is currently active or not * @param {MenuID} menuID The ID of the Menu the section resides in * @param {string} id The ID of the menu section to check * @returns {Observable} Emits true when the given section is currently active, false when the given section is currently inactive */ isSectionActive(menuID: MenuID, id: string): Observable { return this.getMenuSection(menuID, id).pipe(hasValueOperator(), map((section) => section.active)); } /** * Check whether a given section is currently visible or not * @param {MenuID} menuID The ID of the Menu the section resides in * @param {string} id The ID of the menu section to check * @returns {Observable} Emits true when the given section is currently visible, false when the given section is currently hidden */ isSectionVisible(menuID: MenuID, id: string): Observable { return this.getMenuSection(menuID, id).pipe(map((section) => section.visible)); } listenForRouteChanges(): void { this.router.events.pipe( filter(event => event instanceof NavigationEnd), ).subscribe(() => { Object.values(MenuID).forEach((menuID) => { this.buildRouteMenuSections(menuID); }); }); } /** * Build menu sections depending on the current route * - Adds sections found in the current route data that aren't active yet * - Removes sections that are active, but not present in the current route data * @param menuID The menu to add/remove sections to/from */ buildRouteMenuSections(menuID: MenuID) { this.getNonPersistentMenuSections(menuID).pipe( map((sections) => sections.map((section) => section.id)), take(1) ).subscribe((shouldNotPersistIDs: string[]) => { const resolvedSections = this.resolveRouteMenuSections(this.route.root, menuID); resolvedSections.forEach((section) => { const index = shouldNotPersistIDs.indexOf(section.id); if (index > -1) { shouldNotPersistIDs.splice(index, 1); } else { this.addSection(menuID, section); } }); shouldNotPersistIDs.forEach((id) => { this.removeSection(menuID, id); }); }); } /** * Resolve menu sections defined in the current route data (including parent routes) * @param route The route to resolve data for * @param menuID The menu to resolve data for */ resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] { const data = route.snapshot.data; const params = route.snapshot.params; const last: boolean = hasNoValue(route.firstChild); if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) { let menuSections: MenuSection[] | MenuSection = data.menu[menuID]; menuSections = this.resolveSubstitutions(menuSections, params); if (!Array.isArray(menuSections)) { menuSections = [menuSections]; } if (!last) { const childMenuSections = this.resolveRouteMenuSections(route.firstChild, menuID); return [...menuSections.filter(menu => !(childMenuSections).map(childMenu => childMenu.id).includes(menu.id)), ...childMenuSections]; } else { return [...menuSections]; } } return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : []; } protected resolveSubstitutions(object, params) { let resolved; if (isEmpty(params)) { resolved = object; } else if (typeof object === 'string') { resolved = object; Object.entries(params).forEach(([key, value]: [string, string]) => resolved = resolved.replaceAll(`:${key}`, value) ); } else if (Array.isArray(object)) { resolved = []; object.forEach((entry, index) => { resolved[index] = this.resolveSubstitutions(object[index], params); }); } else if (typeof object === 'object') { resolved = {}; Object.keys(object).forEach((key) => { resolved[key] = this.resolveSubstitutions(object[key], params); }); } else { resolved = object; } return resolved; } }