Files
dspace-angular/src/app/shared/menu/menu.service.ts
2023-11-13 16:35:16 -06:00

442 lines
16 KiB
TypeScript

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<T>(key: string, selector): MemoizedSelector<MenuState, T> {
return createSelector(selector, (state) => {
if (hasValue(state)) {
return state[key];
} else {
return undefined;
}
});
}
const menusStateSelector = (state) => state.menus;
const menuByIDSelector = (menuID: MenuID): MemoizedSelector<AppState, MenuState> => {
return keySelector<MenuState>(menuID, menusStateSelector);
};
const menuSectionStateSelector = (state: MenuState) => hasValue(state) ? state.sections : {};
const menuSectionByIDSelector = (id: string): MemoizedSelector<MenuState, MenuSection> => {
return menuKeySelector<MenuSection>(id, menuSectionStateSelector);
};
const menuSectionIndexStateSelector = (state: MenuState) => state.sectionToSubsectionIndex;
const getSubSectionsFromSectionSelector = (id: string): MemoizedSelector<MenuState, string[]> => {
return menuKeySelector<string[]>(id, menuSectionIndexStateSelector);
};
@Injectable()
export class MenuService {
constructor(
protected store: Store<AppState>,
protected route: ActivatedRoute,
protected router: Router,
) {
}
/**
* Retrieve a menu's state by its ID
* @param {MenuID} id ID of the requested Menu
* @returns {Observable<MenuState>} Observable that emits the current state of the requested Menu
*/
getMenu(id: MenuID): Observable<MenuState> {
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<MenuSection[]>} Observable that emits a list of MenuSections that are top sections of the given menu
*/
getMenuTopSections(menuID: MenuID, mustBeVisible = true): Observable<MenuSection[]> {
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<MenuSection[]>} 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<MenuSection[]> {
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<boolean>} 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<boolean> {
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<MenuSection>} Observable that emits the found MenuSection
*/
getMenuSection(menuID: MenuID, sectionId: string): Observable<MenuSection> {
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<MenuSection[]> {
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<boolean>} Emits true if the given menu is collapsed, emits falls when it's expanded
*/
isMenuCollapsed(menuID: MenuID): Observable<boolean> {
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<boolean>} Emits true if the given menu's preview is collapsed, emits falls when it's expanded
*/
isMenuPreviewCollapsed(menuID: MenuID): Observable<boolean> {
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<boolean>} Emits true if the given menu is
* visible and has visible sections, emits false when it's hidden
*/
isMenuVisibleWithVisibleSections(menuID: MenuID): Observable<boolean> {
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<boolean>} Emits true if the given menu is visible, emits falls when it's hidden
*/
isMenuVisible(menuID: MenuID): Observable<boolean> {
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<boolean>} Emits true if the given menu has visible sections, emits false otherwise
*/
menuHasVisibleSections(menuID: MenuID): Observable<boolean> {
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<boolean>} Emits true when the given section is currently active, false when the given section is currently inactive
*/
isSectionActive(menuID: MenuID, id: string): Observable<boolean> {
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<boolean>} Emits true when the given section is currently visible, false when the given section is currently hidden
*/
isSectionVisible(menuID: MenuID, id: string): Observable<boolean> {
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;
}
}