diff --git a/README.md b/README.md index d6b3777..676214b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CurrentNavigation -CurrentNavigation 1.1.3 ist ein SPFx-1.4.1-Application-Customizer für moderne Seiten in SharePoint Server +CurrentNavigation 1.1.4 ist ein SPFx-1.4.1-Application-Customizer für moderne Seiten in SharePoint Server Subscription Edition. Die Solution ergänzt unterhalb der vorhandenen Einträge im modernen SiteNavigation-Bereich einen Treeview für das aktuelle Web. Native Einträge wie Documents bleiben unverändert sichtbar. @@ -12,8 +12,11 @@ Die Steuerung erfolgt über Local Custom Properties: - CurrentNavigation.Hidden = true: Dieser Term und sein kompletter Unterbaum werden ausgeblendet. Die URL des Root-Terms wird mit pageContext.web.absoluteUrl verglichen. Sowohl die Web-URL selbst als auch eine -Homepage unterhalb der Web-URL werden erkannt. Der Root-Term wird standardmäßig nicht dargestellt; seine direkten -Kinder bilden die erste Navigationsebene. +Homepage unterhalb der Web-URL werden erkannt. Der nächstgelegene Root-Term gilt außerdem für alle darunterliegenden +Webs. Der Root-Term wird standardmäßig nicht dargestellt; seine direkten Kinder bilden die erste Navigationsebene. + +`TopNavigation.Hidden` beeinflusst die Current Navigation nicht. Ausschließlich `CurrentNavigation.Hidden` blendet +einen Term und seinen Unterbaum aus. Der Treeview unterstützt auf- und zuklappbare Zweige, aktive Seitenpfade sowie die Navigation mit den Pfeiltasten, Pos1 und Ende. diff --git a/ToDo.md b/ToDo.md index df828a6..7ca8d8c 100644 --- a/ToDo.md +++ b/ToDo.md @@ -1,6 +1,6 @@ # CurrentNavigation – Umsetzungsstand -Zielversion: 1.1.3 +Zielversion: 1.1.4 Status: Implementierung und lokale Tests abgeschlossen; Ship-Build auf Node 8 ausstehend - [x] SPFx Application Customizer für moderne Seiten @@ -11,6 +11,7 @@ Status: Implementierung und lokale Tests abgeschlossen; Ship-Build auf Node 8 au - [x] Cache-Dauer, Cache-Version und Debug-Modus - [x] CurrentNavigation.Root und CurrentNavigation.Hidden - [x] URL-basierte Zuordnung zum aktuellen Web +- [x] Vererbung des nächstgelegenen CurrentNavigation.Root auf darunterliegende Webs - [x] Begrenzbare Navigationstiefe - [x] Sicherer DOM-Renderer ohne innerHTML - [x] Session-Cache mit Stale-Fallback diff --git a/config/package-solution.json b/config/package-solution.json index 833ef1e..85513b1 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "current-navigation-client-side-solution", "id": "bb8f9af5-b318-4111-8401-ce189cfe1b1a", - "version": "1.1.3.0", + "version": "1.1.4.0", "includeClientSideAssets": true, "skipFeatureDeployment": true }, diff --git a/package-lock.json b/package-lock.json index 1716c09..411ee9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "current-navigation", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 534b584..e162e64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "current-navigation", - "version": "1.1.3", + "version": "1.1.4", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/extensions/currentNavigation/CurrentNavigation.module.scss b/src/extensions/currentNavigation/CurrentNavigation.module.scss index c19063f..b85e8a6 100644 --- a/src/extensions/currentNavigation/CurrentNavigation.module.scss +++ b/src/extensions/currentNavigation/CurrentNavigation.module.scss @@ -3,7 +3,6 @@ :global { #CurrentNavigationHost { width: 100%; - border-top: 1px solid "[theme: neutralLight, default: #edebe9]"; } .current-navigation { diff --git a/src/services/CurrentNavigationCore.ts b/src/services/CurrentNavigationCore.ts index d982a2f..929460e 100644 --- a/src/services/CurrentNavigationCore.ts +++ b/src/services/CurrentNavigationCore.ts @@ -36,6 +36,14 @@ export function resolveTermUrl(term: ICurrentNavigationTerm, siteCollectionUrl: export function normalizeNavigationPath(value: string): string { let path: string = String(value || '').trim().toLowerCase(); path = path.replace(/^https?:\/\/[^/]+/i, '').split('#')[0].split('?')[0].replace(/\/+$/, ''); + try { path = decodeURIComponent(path); } catch (error) { /* Keep malformed URLs comparable as supplied. */ } + return path || '/'; +} + +export function normalizeRootWebPath(value: string): string { + let path: string = normalizeNavigationPath(value); + path = path.replace(/\/(?:sitepages|pages)\/.*\.aspx$/i, ''); + path = path.replace(/\/(?:default|home)\.aspx$/i, ''); return path || '/'; } @@ -45,26 +53,58 @@ export function selectCurrentNavigationRoot( siteCollectionUrl: string ): ICurrentNavigationTerm | undefined { const webPath: string = normalizeNavigationPath(currentWebUrl); + const hierarchyRoot: ICurrentNavigationTerm = selectRootByTermHierarchy( + terms, webPath, siteCollectionUrl + ); + if (hierarchyRoot) { return hierarchyRoot; } + let selected: ICurrentNavigationTerm | undefined; let selectedScore: number = -1; for (let i: number = 0; i < terms.length; i++) { if (!parseBoolean(getLocalProperty(terms[i], CurrentNavigationProperties.root))) { continue; } const url: string = resolveTermUrl(terms[i], siteCollectionUrl); if (!url) { continue; } - const candidatePath: string = normalizeNavigationPath(url); + const candidatePath: string = normalizeRootWebPath(url); let score: number = -1; if (candidatePath === webPath) { score = 30000 + candidatePath.length; - } else if (candidatePath.indexOf(webPath + '/') === 0) { - score = 20000 + webPath.length; - } else if (webPath.indexOf(candidatePath + '/') === 0) { - score = 10000 + candidatePath.length; + } else if (isDescendantPath(webPath, candidatePath)) { + score = 20000 + candidatePath.length; } if (score > selectedScore) { selected = terms[i]; selectedScore = score; } } return selected; } +function selectRootByTermHierarchy( + terms: ICurrentNavigationTerm[], + webPath: string, + siteCollectionUrl: string +): ICurrentNavigationTerm | undefined { + let selected: ICurrentNavigationTerm | undefined; + let selectedDepth: number = -1; + for (let i: number = 0; i < terms.length; i++) { + const termUrl: string = resolveTermUrl(terms[i], siteCollectionUrl); + if (!termUrl || normalizeRootWebPath(termUrl) !== webPath) { continue; } + const currentPath: string = String(terms[i].PathOfTerm || '').toLowerCase(); + for (let j: number = 0; j < terms.length; j++) { + if (!parseBoolean(getLocalProperty(terms[j], CurrentNavigationProperties.root))) { continue; } + const rootPath: string = String(terms[j].PathOfTerm || '').toLowerCase(); + const isAncestor: boolean = currentPath === rootPath || currentPath.indexOf(rootPath + ';') === 0; + const rootDepth: number = pathDepth(rootPath); + if (isAncestor && rootDepth > selectedDepth) { + selected = terms[j]; + selectedDepth = rootDepth; + } + } + } + return selected; +} + +function isDescendantPath(path: string, parentPath: string): boolean { + return parentPath === '/' ? path !== '/' : path.indexOf(parentPath + '/') === 0; +} + export function buildCurrentNavigationItems( terms: ICurrentNavigationTerm[], root: ICurrentNavigationTerm, diff --git a/tests/CurrentNavigationCore.test.ts b/tests/CurrentNavigationCore.test.ts index a27de96..9bb2a4d 100644 --- a/tests/CurrentNavigationCore.test.ts +++ b/tests/CurrentNavigationCore.test.ts @@ -1,5 +1,6 @@ import { - buildCurrentNavigationItems, getLocalProperty, parseBoolean, resolveTermUrl, selectCurrentNavigationRoot + buildCurrentNavigationItems, getLocalProperty, normalizeRootWebPath, parseBoolean, resolveTermUrl, + selectCurrentNavigationRoot } from '../src/services/CurrentNavigationCore'; import { ICurrentNavigationTerm } from '../src/services/CurrentNavigationTypes'; @@ -33,6 +34,8 @@ const root: ICurrentNavigationTerm = selectCurrentNavigationRoot( terms, 'https://portal/sites/portal/hr', 'https://portal/sites/portal' ); assert('root is selected through its site home URL', !!root && root.Id === 'root'); +assert('site home URL is normalized to its web', + normalizeRootWebPath('/sites/portal/hr/SitePages/Home.aspx') === '/sites/portal/hr'); const items = buildCurrentNavigationItems(terms, root, 'https://portal/sites/portal', 1031, 3, false); assert('root is not rendered by default', items.length === 1 && items[0].id === 'docs'); assert('descendants are nested', items[0].items.length === 1 && items[0].items[0].id === 'forms'); @@ -44,4 +47,40 @@ const unordered = buildCurrentNavigationItems( ); assert('hierarchy is independent of taxonomy response order', unordered.length === 1 && unordered[0].id === 'docs' && unordered[0].items[0].id === 'forms'); + +const inheritedTerms: ICurrentNavigationTerm[] = [ + term({ Id: 'kits', Name: 'KITS', PathOfTerm: 'Organisation;CIR;KITS', LocalCustomProperties: { + 'CurrentNavigation.Root': 'true', + '_Sys_Nav_SimpleLinkUrl': 'http://dhs.contoso.local/organisation/cir/CIR_3/' + }}), + term({ Id: 'team', Name: 'Team SharePoint', PathOfTerm: 'Organisation;CIR;KITS;Team SharePoint', + LocalCustomProperties: { + 'TopNavigation.Hidden': 'true', + '_Sys_Nav_SimpleLinkUrl': 'http://dhs.contoso.local/organisation/cir/CIR_13/' + }}), + term({ Id: 'games', Name: 'Spielewiese', PathOfTerm: 'Organisation;CIR;KITS;Team SharePoint;Spielewiese', + LocalCustomProperties: { + '_Sys_Nav_SimpleLinkUrl': 'http://dhs.contoso.local/organisation/cir/CIR_21/' + }}) +]; +const inheritedRoot: ICurrentNavigationTerm = selectCurrentNavigationRoot( + inheritedTerms, + 'http://dhs.contoso.local/organisation/cir/CIR_21/', + 'http://dhs.contoso.local/organisation/cir' +); +assert('nearest taxonomy ancestor root applies to a web with a generic URL', + !!inheritedRoot && inheritedRoot.Id === 'kits'); +const teamRoot: ICurrentNavigationTerm = selectCurrentNavigationRoot( + inheritedTerms, + 'http://dhs.contoso.local/organisation/cir/CIR_13/', + 'http://dhs.contoso.local/organisation/cir' +); +assert('taxonomy ancestor root also applies to the direct child web', + !!teamRoot && teamRoot.Id === 'kits'); +const inheritedItems = buildCurrentNavigationItems( + inheritedTerms, inheritedRoot, 'http://dhs.contoso.local/organisation/cir', 1031, 3, false +); +assert('top-navigation hidden does not hide current navigation terms', + inheritedItems.length === 1 && inheritedItems[0].id === 'team' && + inheritedItems[0].items.length === 1 && inheritedItems[0].items[0].id === 'games'); console.log('CurrentNavigation core tests passed:', passed); diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 8808e9b..569b9ea 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -9,8 +9,8 @@ var manifest = json('src/extensions/currentNavigation/CurrentNavigationApplicati var app = read('src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts'); var renderer = read('src/extensions/currentNavigation/CurrentNavigationRenderer.ts'); var service = read('src/services/CurrentNavigationTaxonomyService.ts'); -assert('package version', packageJson.version === '1.1.3'); -assert('solution version', solution.version === '1.1.3.0'); +assert('package version', packageJson.version === '1.1.4'); +assert('solution version', solution.version === '1.1.4.0'); assert('tenant deployment', solution.skipFeatureDeployment === true); assert('component type', manifest.extensionType === 'ApplicationCustomizer'); assert('safe renderer', renderer.indexOf('innerHTML') < 0 && app.indexOf('innerHTML') < 0); @@ -20,8 +20,8 @@ assert('targets SharePoint ms-Nav fallback', app.indexOf('nav[role="navigation"] app.indexOf("querySelector('.ms-Nav-navItems')") >= 0); assert('native navigation is not hidden', app.indexOf('current-navigation-managed') < 0 && read('src/extensions/currentNavigation/CurrentNavigation.module.scss').indexOf(':not(#CurrentNavigationHost)') < 0); -assert('row hover and neutral border', read('src/extensions/currentNavigation/CurrentNavigation.module.scss').indexOf('.current-navigation-row:hover') >= 0 && - read('src/extensions/currentNavigation/CurrentNavigation.module.scss').indexOf('default: #edebe9') >= 0); +assert('whole row hover', + read('src/extensions/currentNavigation/CurrentNavigation.module.scss').indexOf('.current-navigation-row:hover') >= 0); assert('custom navigation is appended below SiteNavigation', app.indexOf('this.siteNavigation.appendChild(this.host)') >= 0); assert('observer cleanup', app.indexOf('observer.disconnect()') >= 0); assert('treeview semantics', renderer.indexOf("setAttribute('role', root ? 'tree' : 'group')") >= 0 &&