feat: Enhance MegaMenu with localization, caching, and new properties

- Added localization support for loading states, empty navigation, and submenu actions in both German and English.
- Introduced new properties in IMenuItem for description, openInNewWindow, and external links.
- Updated SPTermStorePickerService to handle cache versioning and language-specific caching.
- Implemented a new MegaMenuCore service for handling term properties, cache management, and navigation URL resolution.
- Refactored TaxonomyNavigationService to build menu hierarchy and filter hidden terms.
- Added automated tests for MegaMenu core functionalities and static asset validation.
- Removed unused ItemDictionary service.
- Created a comprehensive ToDo document outlining the implementation status and future tasks.
This commit is contained in:
Torsten Brendgen
2026-07-20 21:34:24 +02:00
parent bc61166267
commit 50b85468f1
30 changed files with 1394 additions and 294 deletions
+30 -28
View File
@@ -9,9 +9,9 @@ import { ISPTermStorePickerServiceProps } from './ISPTermStorePickerServiceProps
import { IPickerTerm } from './IPickerTerm';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import { debugError, debugLog, debugWarn } from './MegaMenuDebug';
import { buildTermCacheKey, compareTermsByConfiguredOrder, readCachedValue } from './MegaMenuCore';
const EmptyGuid: string = '00000000-0000-0000-0000-000000000000';
const TermCachePrefix: string = 'MegaMenu:TermSet:v2:';
const PickerCachePrefix: string = 'MegaMenu:PickerTerms:v2:';
interface ITermCacheEntry {
@@ -41,7 +41,9 @@ export default class SPTermStorePickerService {
clientServiceUrl: this.clientServiceUrl,
termSetNameOrId: this.props.termsetNameOrID,
useSessionStorage: this.props.useSessionStorage,
cacheMinutes: this.props.cacheMinutes
cacheMinutes: this.props.cacheMinutes,
cacheVersion: this.props.cacheVersion,
languageLcid: this.props.languageLcid
});
}
@@ -175,7 +177,8 @@ export default class SPTermStorePickerService {
}
}
const childTerms = this.getCachedTermSet(termsetId, useSessionStorage);
const childTerms = this.getCachedTermSet(termsetId, useSessionStorage, false);
const staleTerms = this.getCachedTermSet(termsetId, useSessionStorage, true);
if (childTerms) {
debugLog(this.debug, 'SPTermStorePickerService', 'Returning terms from session cache.', {
@@ -272,9 +275,10 @@ export default class SPTermStorePickerService {
expiresAt: Date.now() + ttlMinutes * 60 * 1000,
value: termStoreResultTermSet
};
window.sessionStorage.setItem(TermCachePrefix + termsetId, JSON.stringify(cacheEntry));
const cacheKey: string = this.getTermCacheKey(termsetId);
window.sessionStorage.setItem(cacheKey, JSON.stringify(cacheEntry));
debugLog(this.debug, 'SPTermStorePickerService', 'Term set stored in session cache.', {
key: TermCachePrefix + termsetId,
key: cacheKey,
expiresAt: cacheEntry.expiresAt
});
}
@@ -290,6 +294,12 @@ export default class SPTermStorePickerService {
debugWarn(this.debug, 'SPTermStorePickerService', 'ProcessQuery response contained no term set.');
return null;
});
}).catch((error: any) => {
if (staleTerms) {
debugWarn(this.debug, 'SPTermStorePickerService', 'Taxonomy request failed; returning stale cache.', error);
return staleTerms;
}
throw error;
});
}
@@ -420,21 +430,20 @@ export default class SPTermStorePickerService {
return null;
}
private getCachedTermSet(termId: string, useSessionStorage: boolean = true): ITermSet {
private getCachedTermSet(termId: string, useSessionStorage: boolean = true, allowExpired: boolean = false): ITermSet {
try {
if (useSessionStorage && window.sessionStorage) {
const key: string = TermCachePrefix + termId;
const key: string = this.getTermCacheKey(termId);
const terms: string = window.sessionStorage.getItem(key);
if (!terms) {
return null;
}
const cacheEntry: ITermCacheEntry = JSON.parse(terms) as ITermCacheEntry;
if (!cacheEntry || !cacheEntry.value || !cacheEntry.expiresAt || cacheEntry.expiresAt <= Date.now()) {
const cachedValue: ITermSet = readCachedValue<ITermSet>(terms, Date.now(), allowExpired);
if (!cachedValue && allowExpired) {
window.sessionStorage.removeItem(key);
return null;
}
return cacheEntry.value;
return cachedValue || null;
} else {
return null;
}
@@ -473,6 +482,15 @@ export default class SPTermStorePickerService {
return Math.min(Math.max(Math.floor(value), 1), 1440);
}
private getTermCacheKey(termId: string): string {
return buildTermCacheKey(
this.props.siteCollectionUrl,
termId,
this.props.languageLcid,
this.props.cacheVersion
);
}
private ensureSuccessfulResponse(response: SPHttpClientResponse, operation: string): void {
if (!response.ok) {
throw new Error(operation + ' request failed (' + response.status + ' ' + response.statusText + ').');
@@ -623,23 +641,7 @@ export default class SPTermStorePickerService {
* @param b term 2
*/
private sortTermByPath(a: ITerm, b: ITerm) {
if (a.CustomSortOrderIndex === -1) {
if (a.PathOfTerm.toLowerCase() < b.PathOfTerm.toLowerCase()) {
return -1;
}
if (a.PathOfTerm.toLowerCase() > b.PathOfTerm.toLowerCase()) {
return 1;
}
return 0;
} else {
if (a.CustomSortOrderIndex < b.CustomSortOrderIndex) {
return -1;
}
if (a.CustomSortOrderIndex > b.CustomSortOrderIndex) {
return 1;
}
return 0;
}
return compareTermsByConfiguredOrder(a, b);
}
private convertTermToPickerTerm(term: ITerm): IPickerTerm {