diff --git a/README.md b/README.md index 3458cb4..08eb266 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Barrierearme, dreistufige Portalnavigation auf Basis von SharePoint Managed Meta | Eigenschaft | Wert | |---|---| -| Version | 1.1.2 | +| Version | 1.1.3 | | SharePoint Framework | SPFx 1.4.1 | | Node.js | 8.17.0 | | Zieloberfläche | Moderne SharePoint-Seiten | @@ -214,6 +214,13 @@ Weder eine gültige `termSetId` noch ein `termSetName` ist gesetzt. UserCustomAc Für weitere technische Details kann vorübergehend `debug: true` gesetzt werden. +Bei aktiviertem Debug-Modus protokolliert MegaMenu den kompletten Startpfad mit den Quellen +`MegaMenuApplicationCustomizer`, `TaxonomyNavigationService`, `SPTermStorePickerService` und +`MegaMenuRenderer`. Dazu gehören die übergebenen Properties, Placeholder-Erkennung, +Taxonomy-Anfrage, Cache-Nutzung, Term-Anzahl, Containerwahl und vollständige Fehlerdetails. +Wenn keine dieser Meldungen erscheint, wurde das MegaMenu-Bundle nicht durch den SPFx-Loader +ausgeführt. + ### Änderungen am Termset erscheinen verzögert Die Navigation wird bis zu `cacheMinutes` im Session-Cache gehalten. Cache leeren oder Ablaufzeit abwarten. @@ -254,6 +261,12 @@ MegaMenu/ ## Versionshistorie +### 1.1.3 + +- Ausführliches, ausschließlich über `debug: true` aktiviertes Lifecycle-Logging ergänzt. +- Taxonomy-Anfragen, Session-Cache, Term-Filterung und Renderer werden nachvollziehbar protokolliert. +- Fehlerausgaben enthalten Name, Meldung und Stacktrace. + ### 1.1.2 - `cssUrl` zugunsten der zentralen Custom-Branding-Solution entfernt diff --git a/config/package-solution.json b/config/package-solution.json index 0ca7a50..8dc21db 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "mega-menu-client-side-solution", "id": "f4660e06-ce08-43ee-bfb7-5c4464e01133", - "version": "1.1.2.0", + "version": "1.1.3.0", "includeClientSideAssets": true, "skipFeatureDeployment": false, "features": [ @@ -11,7 +11,7 @@ "title": "MegaMenu extension registration", "description": "Registers the MegaMenu Application Customizer in the host web.", "id": "1e4fdc74-8053-48b2-a15b-e975d61eb71f", - "version": "1.1.2.0", + "version": "1.1.3.0", "assets": { "elementManifests": [ "elements.xml" diff --git a/package-lock.json b/package-lock.json index 42ac905..1ab98c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "mega-menu", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 7d9f9a9..880690f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mega-menu", - "version": "1.1.2", + "version": "1.1.3", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/extensions/megaMenu/MegaMenuApplicationCustomizer.ts b/src/extensions/megaMenu/MegaMenuApplicationCustomizer.ts index 7ca00ae..bbb60a7 100644 --- a/src/extensions/megaMenu/MegaMenuApplicationCustomizer.ts +++ b/src/extensions/megaMenu/MegaMenuApplicationCustomizer.ts @@ -37,40 +37,66 @@ export default class MegaMenuApplicationCustomizer @override public onInit(): Promise { - debugLog(this._isDebugEnabled(), LOG_SOURCE, 'Initialized ' + strings.Title); + const debug: boolean = this._isDebugEnabled(); + debugLog(debug, LOG_SOURCE, 'onInit started.', { + componentId: UserCustomActionMegaMenuId, + version: '1.1.3', + properties: this.properties || {}, + pageUrl: window.location.href, + webUrl: this.context.pageContext.web.absoluteUrl, + siteUrl: this.context.pageContext.site.absoluteUrl + }); this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceHolders); + debugLog(debug, LOG_SOURCE, 'Placeholder changedEvent handler registered.'); this._renderPlaceHolders(); + debugLog(debug, LOG_SOURCE, 'onInit completed. Asynchronous menu loading may still be running.'); return Promise.resolve(); } private _renderPlaceHolders(): void { + const debug: boolean = this._isDebugEnabled(); const availablePlaceholders: string = this.context.placeholderProvider.placeholderNames .map(name => PlaceholderName[name]) .join(', '); - debugLog(this._isDebugEnabled(), LOG_SOURCE, 'Available placeholders:', availablePlaceholders); + debugLog(debug, LOG_SOURCE, 'Rendering placeholders.', { + availablePlaceholders: availablePlaceholders, + topPlaceholderAlreadyCreated: !!this._topPlaceholder + }); if (!this._topPlaceholder) { + debugLog(debug, LOG_SOURCE, 'Requesting the Top placeholder.'); this._topPlaceholder = this.context.placeholderProvider.tryCreateContent( PlaceholderName.Top, { onDispose: this._onDispose } ); if (!this._topPlaceholder) { - debugError(this._isDebugEnabled(), LOG_SOURCE, 'The expected placeholder (Top) was not found.'); + debugError(debug, LOG_SOURCE, 'The expected placeholder (Top) was not found.'); return; } + debugLog(debug, LOG_SOURCE, 'Top placeholder acquired.', { + domElementId: this._topPlaceholder.domElement.id || '', + domElementClassName: this._topPlaceholder.domElement.className || '' + }); + const termSetIdentifier: string = this._getTermSetIdentifier(); if (!termSetIdentifier) { - debugWarn(this._isDebugEnabled(), LOG_SOURCE, 'No termSetId or termSetName configured. Mega Menu rendering is skipped.'); + debugWarn(debug, LOG_SOURCE, 'No termSetId or termSetName configured. Mega Menu rendering is skipped.', this.properties || {}); this._renderStatus(strings.ConfigurationMissing); return; } - this._renderMegaMenu(termSetIdentifier); + debugLog(debug, LOG_SOURCE, 'Starting MegaMenu rendering.', { + termSetIdentifier: termSetIdentifier, + cacheMinutes: this._getCacheMinutes() + }); + this._renderMegaMenu(termSetIdentifier, debug); + } else { + debugLog(debug, LOG_SOURCE, 'Top placeholder is already initialized; no duplicate render is started.'); } } @@ -80,6 +106,7 @@ export default class MegaMenuApplicationCustomizer } try { + debugLog(debug, LOG_SOURCE, 'Creating TaxonomyNavigationService.'); const taxonomyService: TaxonomyNavigationService = new TaxonomyNavigationService( this.context, termSetIdentifier, @@ -87,9 +114,14 @@ export default class MegaMenuApplicationCustomizer this._getCacheMinutes() ); + debugLog(debug, LOG_SOURCE, 'Requesting navigation items from taxonomy.'); const menuItems: IMenuItem[] = await taxonomyService.getMenuItems(); + debugLog(debug, LOG_SOURCE, 'Navigation items received.', { + topLevelItemCount: menuItems.length + }); if (this._renderer) { + debugLog(debug, LOG_SOURCE, 'Disposing the previous renderer instance.'); this._renderer.dispose(); } @@ -101,14 +133,19 @@ export default class MegaMenuApplicationCustomizer this._menuContainer = this._getOrCreateContainer('CustomHeader', this._topPlaceholder); if (this._menuContainer) { + debugLog(debug, LOG_SOURCE, 'Rendering into the selected container.', { + id: this._menuContainer.id || '', + className: this._menuContainer.className || '' + }); this._renderer.render(this._menuContainer); } else { + debugWarn(debug, LOG_SOURCE, 'No dedicated container was returned; rendering into the Top placeholder.'); this._renderer.render(this._topPlaceholder.domElement); } debugLog(debug, LOG_SOURCE, 'MegaMenu rendered successfully with ' + menuItems.length + ' top-level items.'); } catch (error) { - debugError(debug, LOG_SOURCE, 'Error rendering MegaMenu.', error); + debugError(debug, LOG_SOURCE, 'Error rendering MegaMenu.', this._describeError(error), error); this._renderStatus(strings.LoadError); } } @@ -117,15 +154,26 @@ export default class MegaMenuApplicationCustomizer const container = document.getElementById(id); if (container) { + debugLog(this._isDebugEnabled(), LOG_SOURCE, 'Existing host container found.', { + requestedId: id, + containerId: container.id, + containerClassName: container.className || '' + }); let host: HTMLElement = document.getElementById('MegaMenuHost'); if (!host) { host = document.createElement('div'); host.id = 'MegaMenuHost'; container.appendChild(host); + debugLog(this._isDebugEnabled(), LOG_SOURCE, 'MegaMenuHost was created inside the existing host container.'); + } else { + debugLog(this._isDebugEnabled(), LOG_SOURCE, 'Existing MegaMenuHost will be reused.'); } return host; } + debugLog(this._isDebugEnabled(), LOG_SOURCE, 'No external host container found; the Top placeholder will be used.', { + requestedId: id + }); return placeholder.domElement; } @@ -175,6 +223,14 @@ export default class MegaMenuApplicationCustomizer return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(value); } + private _describeError(error: any): { name: string; message: string; stack: string } { // tslint:disable-line:no-any + return { + name: error && error.name ? String(error.name) : '', + message: error && error.message ? String(error.message) : String(error || 'Unknown error'), + stack: error && error.stack ? String(error.stack) : '' + }; + } + private _isDebugEnabled(debugOverride?: boolean): boolean { if (typeof debugOverride === 'boolean') { return debugOverride; diff --git a/src/extensions/megaMenu/MegaMenuRenderer.ts b/src/extensions/megaMenu/MegaMenuRenderer.ts index 7f834c1..44d8044 100644 --- a/src/extensions/megaMenu/MegaMenuRenderer.ts +++ b/src/extensions/megaMenu/MegaMenuRenderer.ts @@ -24,6 +24,11 @@ export class MegaMenuRenderer { ) { } public render(container: HTMLElement): void { + debugLog(this.debug, LOG_SOURCE, 'Render started.', { + menuItemCount: this.menuItems.length, + containerId: container.id || '', + containerClassName: container.className || '' + }); this.dispose(); this._container = container; container.innerHTML = ''; @@ -47,9 +52,17 @@ export class MegaMenuRenderer { this.attachEventListeners(); this.createScreenReaderAnnouncer(); + debugLog(this.debug, LOG_SOURCE, 'Render completed.', { + navigationId: nav.id, + topLevelElements: topLevelUl.children.length + }); } public dispose(): void { + debugLog(this.debug, LOG_SOURCE, 'Dispose started.', { + hasContainer: !!this._container, + hasAnnouncer: !!this._announcer + }); if (this._documentKeydownHandler) { document.removeEventListener('keydown', this._documentKeydownHandler); this._documentKeydownHandler = undefined; @@ -66,6 +79,7 @@ export class MegaMenuRenderer { this._container.innerHTML = ''; } this._container = undefined; + debugLog(this.debug, LOG_SOURCE, 'Dispose completed.'); } private createTopLevelItem(item: IMenuItem): HTMLLIElement { diff --git a/src/services/SPTermStorePickerService.ts b/src/services/SPTermStorePickerService.ts index 76340a8..07e076f 100644 --- a/src/services/SPTermStorePickerService.ts +++ b/src/services/SPTermStorePickerService.ts @@ -9,7 +9,7 @@ import { findIndex } from '@microsoft/sp-lodash-subset'; import { ISPTermStorePickerServiceProps } from './ISPTermStorePickerServiceProps'; import { IPickerTerm } from './IPickerTerm'; import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; -import { debugError } from './MegaMenuDebug'; +import { debugError, debugLog, debugWarn } from './MegaMenuDebug'; const EmptyGuid: string = '00000000-0000-0000-0000-000000000000'; const TermCachePrefix: string = 'MegaMenu:TermSet:v2:'; @@ -38,6 +38,12 @@ export default class SPTermStorePickerService { constructor(private props: ISPTermStorePickerServiceProps, private context: ApplicationCustomizerContext, private debug: boolean = false) { this.clientServiceUrl = this.context.pageContext.web.absoluteUrl + '/_vti_bin/client.svc/ProcessQuery'; this.suggestionServiceUrl = this.context.pageContext.web.absoluteUrl + '/_vti_bin/TaxonomyInternalService.json/GetSuggestions'; + debugLog(this.debug, 'SPTermStorePickerService', 'Service initialized.', { + clientServiceUrl: this.clientServiceUrl, + termSetNameOrId: this.props.termsetNameOrID, + useSessionStorage: this.props.useSessionStorage, + cacheMinutes: this.props.cacheMinutes + }); } public async getTermLabels(termId: string): Promise { @@ -146,6 +152,13 @@ export default class SPTermStorePickerService { cacheMinutes: number = 15 ): Promise { let termsetId: string = termset; + debugLog(this.debug, 'SPTermStorePickerService', 'getAllTerms started.', { + configuredTermSet: termset, + hideDeprecatedTags: hideDeprecatedTags === true, + hideTagsNotAvailableForTagging: hideTagsNotAvailableForTagging === true, + useSessionStorage: useSessionStorage, + cacheMinutes: cacheMinutes + }); // Check if the provided term set property is a GUID or string if (!this.isGuid(termset)) { // Fetch the term store information @@ -154,7 +167,11 @@ export default class SPTermStorePickerService { const crntTermSet = this.getTermSetId(termStore, termset); if (crntTermSet) { termsetId = this.cleanGuid(crntTermSet.Id); + debugLog(this.debug, 'SPTermStorePickerService', 'Term set name resolved to an ID.', { + termsetId: termsetId + }); } else { + debugWarn(this.debug, 'SPTermStorePickerService', 'Configured term set could not be resolved.', termset); return null; } } @@ -162,9 +179,18 @@ export default class SPTermStorePickerService { const childTerms = this.getCachedTermSet(termsetId, useSessionStorage); if (childTerms) { + debugLog(this.debug, 'SPTermStorePickerService', 'Returning terms from session cache.', { + termsetId: termsetId, + termCount: childTerms.Terms ? childTerms.Terms.length : 0 + }); return childTerms; } + debugLog(this.debug, 'SPTermStorePickerService', 'No valid cache entry found; sending ProcessQuery request.', { + termsetId: termsetId, + url: this.clientServiceUrl + }); + // Request body to retrieve all terms for the given term set const data = `${termsetId}`; @@ -178,6 +204,11 @@ export default class SPTermStorePickerService { }; return this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { + debugLog(this.debug, 'SPTermStorePickerService', 'ProcessQuery response received.', { + status: serviceResponse.status, + statusText: serviceResponse.statusText, + ok: serviceResponse.ok + }); this.ensureSuccessfulResponse(serviceResponse, 'Terms'); return serviceResponse.json().then((serviceJSONResponse: any) => { if (!Array.isArray(serviceJSONResponse)) { @@ -195,11 +226,21 @@ export default class SPTermStorePickerService { let terms = termStoreResultTerms[0]._Child_Items_; if (hideDeprecatedTags === true) { + const countBeforeDeprecatedFilter: number = terms.length; terms = terms.filter(d => d.IsDeprecated === false); + debugLog(this.debug, 'SPTermStorePickerService', 'Deprecated terms filtered.', { + before: countBeforeDeprecatedFilter, + after: terms.length + }); } if (hideTagsNotAvailableForTagging === true) { + const countBeforeAvailableFilter: number = terms.length; terms = terms.filter(d => d.IsAvailableForTagging === true); + debugLog(this.debug, 'SPTermStorePickerService', 'Terms unavailable for tagging filtered.', { + before: countBeforeAvailableFilter, + after: terms.length + }); } // Clean the term ID and specify the path depth @@ -233,12 +274,21 @@ export default class SPTermStorePickerService { value: termStoreResultTermSet }; window.sessionStorage.setItem(TermCachePrefix + termsetId, JSON.stringify(cacheEntry)); + debugLog(this.debug, 'SPTermStorePickerService', 'Term set stored in session cache.', { + key: TermCachePrefix + termsetId, + expiresAt: cacheEntry.expiresAt + }); } } catch (error) { - // Do nothing, sometimes "storage quota exceeded" error if too many items + debugWarn(this.debug, 'SPTermStorePickerService', 'Term cache could not be written.', error); } + debugLog(this.debug, 'SPTermStorePickerService', 'getAllTerms completed.', { + termsetId: termsetId, + termCount: termStoreResultTermSet.Terms ? termStoreResultTermSet.Terms.length : 0 + }); return termStoreResultTermSet; } + debugWarn(this.debug, 'SPTermStorePickerService', 'ProcessQuery response contained no term set.'); return null; }); }); diff --git a/src/services/TaxonomyNavigationService.ts b/src/services/TaxonomyNavigationService.ts index 214eeac..28974a7 100644 --- a/src/services/TaxonomyNavigationService.ts +++ b/src/services/TaxonomyNavigationService.ts @@ -6,7 +6,7 @@ import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; import SPTermStorePickerService from './SPTermStorePickerService'; import { ITerm, ITermSet } from './ISPTermStorePickerService'; import { ItemDictionary } from './ItemDictionary'; -import { debugWarn } from './MegaMenuDebug'; +import { debugLog, debugWarn } from './MegaMenuDebug'; const LOG_SOURCE: string = 'TaxonomyNavigationService'; @@ -18,6 +18,12 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService { private debug: boolean = false, private cacheMinutes: number = 15 ) { + debugLog(this.debug, LOG_SOURCE, 'Initializing taxonomy navigation service.', { + termSetNameOrId: this.termSetNameOrId, + cacheMinutes: this.cacheMinutes, + webUrl: this.context.pageContext.web.absoluteUrl, + siteUrl: this.context.pageContext.site.absoluteUrl + }); sp.setup({ spfxContext: context }); @@ -37,6 +43,10 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService { public async getMenuItems(): Promise { const siteCollectionUrl: string = this.context.pageContext.site.absoluteUrl; + debugLog(this.debug, LOG_SOURCE, 'Reading all terms.', { + termSetNameOrId: this.termSetNameOrId, + cacheMinutes: this.cacheMinutes + }); const termset: ITermSet = await this._taxonomyPickerService.getAllTerms( this.termSetNameOrId, true, @@ -44,6 +54,10 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService { true, this.cacheMinutes ); + debugLog(this.debug, LOG_SOURCE, 'Term set response received.', { + termSetFound: !!termset, + termCount: termset && termset.Terms ? termset.Terms.length : 0 + }); const itemsDict: ItemDictionary = new ItemDictionary(); const menuItems: IMenuItem[] = []; @@ -67,6 +81,10 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService { } }); + debugLog(this.debug, LOG_SOURCE, 'Navigation hierarchy built.', { + totalTerms: termset.Terms.length, + topLevelItems: menuItems.length + }); return menuItems; } }