Add Current Navigation Application Customizer

- Created a new SPFx Application Customizer for current navigation.
- Implemented core functionality to fetch and render navigation items from a SharePoint term set.
- Added styles for the navigation component using SCSS.
- Included localization support for English and German languages.
- Developed unit tests for core navigation functionalities.
- Configured TypeScript and TSLint settings for the project.
- Added validation scripts for static assets and package configuration.
This commit is contained in:
Torsten Brendgen
2026-08-18 17:13:35 +02:00
commit 0a3219117a
28 changed files with 18388 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
lib/
temp/
dist/
sharepoint/solution/debug/
sharepoint/solution/*.sppkg
npm-debug.log*
*.tsbuildinfo
+25
View File
@@ -0,0 +1,25 @@
# CurrentNavigation
CurrentNavigation 1.0.0 ist ein SPFx-1.4.1-Application-Customizer für moderne Seiten in SharePoint Server
Subscription Edition. Die Solution rendert im oberen Placeholder eine Navigation für das aktuelle Web.
## Term-Konfiguration
Die Steuerung erfolgt über Local Custom Properties:
- CurrentNavigation.Root = true: Dieser Term ist der Navigationswurzelpunkt einer Website.
- 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.
## Build und Bereitstellung
Voraussetzungen: Node.js 8.17.0, npm 6.x und lokales Gulp 3.9.1.
Build: npm install, npm test, npm run package.
Das Paket entsteht unter sharepoint/solution/current-navigation.sppkg. Es wird im App Catalog zentral bereitgestellt
und anschließend über PortalSettings 3.1.0 aktiviert. Alternativ dient deployment/add-currentnavigation.ps1 zur
site-collection-weiten Registrierung.
+14
View File
@@ -0,0 +1,14 @@
# CurrentNavigation – Umsetzungsstand
Zielversion: 1.0.0
Status: Implementierung und lokale Tests abgeschlossen; Ship-Build auf Node 8 ausstehend
- [x] SPFx Application Customizer für moderne Seiten
- [x] Site-Collection-weite Registrierung
- [x] Termset-Picker über PortalSettings
- [x] Cache-Dauer, Cache-Version und Debug-Modus
- [x] CurrentNavigation.Root und CurrentNavigation.Hidden
- [x] URL-basierte Zuordnung zum aktuellen Web
- [x] Begrenzbare Navigationstiefe
- [x] Sicherer DOM-Renderer ohne innerHTML
- [x] Session-Cache mit Stale-Fallback
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"current-navigation-application-customizer": {
"components": [{
"entrypoint": "./lib/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.js",
"manifest": "./src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.manifest.json"
}]
}
},
"externals": {},
"localizedResources": {
"CurrentNavigationApplicationCustomizerStrings": "lib/extensions/currentNavigation/loc/{locale}.js"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "current-navigation-client-side-solution",
"id": "bb8f9af5-b318-4111-8401-ce189cfe1b1a",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
"paths": { "zippedPackage": "solution/current-navigation.sppkg" }
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"serveConfigurations": {
"default": {
"pageUrl": "https://sharepoint.contoso.local/sites/portal/SitePages/Home.aspx",
"customActions": {
"e37b828f-4315-461a-b1b0-fcf991deda3d": {
"location": "ClientSideExtension.ApplicationCustomizer",
"properties": {
"termSetId": "00000000-0000-0000-0000-000000000000",
"cacheMinutes": 15, "cacheVersion": "1", "maxDepth": 3,
"showRoot": false, "debug": true
}
}
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}
+58
View File
@@ -0,0 +1,58 @@
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$TermSetId,
[int]$CacheMinutes = 15,
[string]$CacheVersion = '1',
[ValidateRange(1, 8)][int]$MaxDepth = 3,
[switch]$ShowRoot,
[switch]$EnableDebug
)
if (-not (Get-Command -Name Get-SPSite -ErrorAction SilentlyContinue)) {
if (Get-Module -ListAvailable -Name Microsoft.SharePoint.PowerShell) {
Import-Module Microsoft.SharePoint.PowerShell -DisableNameChecking
} elseif (Get-PSSnapin -Registered -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) {
Add-PSSnapin Microsoft.SharePoint.PowerShell
} else { throw 'Bitte in der SharePoint Management Shell ausführen.' }
}
$componentId = [Guid]'e37b828f-4315-461a-b1b0-fcf991deda3d'
$parsedTermSetId = [Guid]::Empty
if (-not [Guid]::TryParse($TermSetId, [ref]$parsedTermSetId)) { throw 'TermSetId ist keine gültige GUID.' }
$properties = @{
schemaVersion = 1
portalSettings = @{ providerKey = 'currentnavigation'; contractVersion = 1; minimumPortalSettingsVersion = '3.1.0' }
termSetId = $parsedTermSetId.ToString()
termSetName = ''
cacheMinutes = [Math]::Min([Math]::Max($CacheMinutes, 1), 1440)
cacheVersion = $(if ([string]::IsNullOrWhiteSpace($CacheVersion)) { '1' } else { $CacheVersion.Trim() })
maxDepth = $MaxDepth
showRoot = [bool]$ShowRoot.IsPresent
debug = [bool]$EnableDebug.IsPresent
}
$site = Get-SPSite -Identity $SiteUrl
try {
$actions = @($site.UserCustomActions | Where-Object {
$_.Location -eq 'ClientSideExtension.ApplicationCustomizer' -and $_.ClientSideComponentId -eq $componentId
})
$action = $actions | Select-Object -First 1
if (-not $action) { $action = $site.UserCustomActions.Add() }
$action.Name = 'CurrentNavigation'
$action.Title = 'Current Navigation Application Customizer'
$action.Description = 'MSFT-Custom-Solution:CurrentNavigation'
$action.Location = 'ClientSideExtension.ApplicationCustomizer'
$action.Sequence = 110
$action.ClientSideComponentId = $componentId
$action.ClientSideComponentProperties = $properties | ConvertTo-Json -Depth 10 -Compress
$action.Update()
@($actions | Select-Object -Skip 1) | ForEach-Object { $_.Delete() }
foreach ($web in $site.AllWebs) {
try {
@($web.UserCustomActions | Where-Object {
$_.Location -eq 'ClientSideExtension.ApplicationCustomizer' -and $_.ClientSideComponentId -eq $componentId
}) | ForEach-Object { $_.Delete() }
} finally { $web.Dispose() }
}
Write-Host 'CurrentNavigation wurde für die Site Collection registriert.' -ForegroundColor Green
} finally { if ($site) { $site.Dispose() } }
+3
View File
@@ -0,0 +1,3 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.initialize(require('gulp'));
+17534
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "current-navigation",
"version": "1.0.0",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=6.9.0 <9.0.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "tsc -p tests/tsconfig.json && node temp/tests/tests/CurrentNavigationCore.test.js && node tests/validate-static-assets.js",
"package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship"
},
"dependencies": {
"@microsoft/decorators": "~1.4.1",
"@microsoft/sp-application-base": "~1.4.1",
"@microsoft/sp-core-library": "~1.4.1",
"@microsoft/sp-http": "~1.4.1",
"@microsoft/sp-office-ui-fabric-core": "^1.4.1",
"@types/es6-promise": "0.0.33",
"@types/webpack-env": "1.13.1"
},
"devDependencies": {
"@microsoft/sp-build-web": "~1.4.1",
"@microsoft/sp-module-interfaces": "~1.4.1",
"@microsoft/sp-webpart-workbench": "~1.4.1",
"gulp": "~3.9.1",
"ajv": "~5.2.2"
}
}
@@ -0,0 +1,91 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
:global {
.current-navigation {
position: relative;
z-index: 299;
width: 100%;
border-bottom: 1px solid $ms-color-neutralLight;
background: $ms-color-white;
color: $ms-color-neutralPrimary;
font-family: $ms-font-family-fallbacks;
font-size: $ms-font-size-s;
}
.current-navigation ul {
margin: 0;
padding: 0;
list-style: none;
}
.current-navigation-root {
display: flex;
min-height: 40px;
padding: 0 16px !important;
}
.current-navigation li {
position: relative;
}
.current-navigation a,
.current-navigation span {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 12px;
color: inherit;
text-decoration: none;
white-space: nowrap;
}
.current-navigation a:hover,
.current-navigation a:focus,
.current-navigation a.is-current {
color: $ms-color-themePrimary;
background: $ms-color-neutralLighter;
}
.current-navigation a:focus {
outline: 2px solid $ms-color-themePrimary;
outline-offset: -2px;
}
.current-navigation-children {
position: absolute;
top: 100%;
left: 0;
display: none;
min-width: 220px;
border: 1px solid $ms-color-neutralLight;
background: $ms-color-white;
box-shadow: 0 4px 12px rgba(0, 0, 0, .16);
}
.current-navigation-children .current-navigation-children {
top: 0;
left: 100%;
}
.current-navigation li:hover > .current-navigation-children,
.current-navigation li:focus-within > .current-navigation-children {
display: block;
}
@media (max-width: 767px) {
.current-navigation-root {
flex-wrap: wrap;
padding: 0 8px !important;
}
.current-navigation-children,
.current-navigation-children .current-navigation-children {
position: static;
display: block;
min-width: 0;
border: 0;
box-shadow: none;
padding-left: 16px;
}
}
}
@@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-extension-manifest.schema.json",
"id": "e37b828f-4315-461a-b1b0-fcf991deda3d",
"alias": "CurrentNavigationApplicationCustomizer",
"componentType": "Extension",
"extensionType": "ApplicationCustomizer",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false
}
@@ -0,0 +1,125 @@
// tslint:disable:max-line-length variable-name
import { override } from '@microsoft/decorators';
import { BaseApplicationCustomizer, PlaceholderContent, PlaceholderName } from '@microsoft/sp-application-base';
import * as strings from 'CurrentNavigationApplicationCustomizerStrings';
import {
buildCurrentNavigationItems,
selectCurrentNavigationRoot
} from '../../services/CurrentNavigationCore';
import { CurrentNavigationTaxonomyService } from '../../services/CurrentNavigationTaxonomyService';
import { ICurrentNavigationItem, ICurrentNavigationTerm, ITermSetResult } from '../../services/CurrentNavigationTypes';
import { CurrentNavigationRenderer } from './CurrentNavigationRenderer';
import './CurrentNavigation.module.scss';
export interface ICurrentNavigationApplicationCustomizerProperties {
termSetId?: string;
termSetName?: string;
cacheMinutes?: number;
cacheVersion?: string;
maxDepth?: number;
showRoot?: boolean;
debug?: boolean;
}
export default class CurrentNavigationApplicationCustomizer
extends BaseApplicationCustomizer<ICurrentNavigationApplicationCustomizerProperties> {
private topPlaceholder: PlaceholderContent | undefined;
private host: HTMLElement | undefined;
private renderer: CurrentNavigationRenderer = new CurrentNavigationRenderer();
private renderVersion: number = 0;
@override
public onInit(): Promise<void> {
this.context.placeholderProvider.changedEvent.add(this, this.renderPlaceholders);
const application: any = this.context.application as any; // tslint:disable-line:no-any
if (application && application.navigatedEvent) {
application.navigatedEvent.add(this, this.onNavigated);
}
this.renderPlaceholders();
return Promise.resolve();
}
private renderPlaceholders = (): void => {
if (!this.topPlaceholder) {
this.topPlaceholder = this.context.placeholderProvider.tryCreateContent(
PlaceholderName.Top, { onDispose: this.onPlaceholderDisposed }
);
}
if (!this.topPlaceholder) { return; }
if (!this.host) {
this.host = document.createElement('div');
this.host.id = 'CurrentNavigationHost';
this.topPlaceholder.domElement.appendChild(this.host);
}
this.renderNavigation();
}
private onNavigated = (): void => { this.renderNavigation(); }
private async renderNavigation(): Promise<void> {
const version: number = ++this.renderVersion;
if (!this.host) { return; }
this.renderer.clear(this.host);
const termSetId: string = this.properties && this.properties.termSetId
? String(this.properties.termSetId).trim() : '';
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(termSetId)) {
this.debug(strings.ConfigurationMissing);
return;
}
try {
const service: CurrentNavigationTaxonomyService = new CurrentNavigationTaxonomyService(
this.context, termSetId, this.cacheMinutes(), this.cacheVersion(),
this.languageLcid(), this.isDebug()
);
const termSet: ITermSetResult = await service.getTerms();
if (version !== this.renderVersion || !this.host) { return; }
const root: ICurrentNavigationTerm = selectCurrentNavigationRoot(
termSet.Terms, this.context.pageContext.web.absoluteUrl, this.context.pageContext.site.absoluteUrl
);
if (!root) { this.debug('No CurrentNavigation.Root term matches the current web.'); return; }
const items: ICurrentNavigationItem[] = buildCurrentNavigationItems(
termSet.Terms, root, this.context.pageContext.site.absoluteUrl,
this.languageLcid(), this.maxDepth(), !!(this.properties && this.properties.showRoot)
);
if (items.length) { this.renderer.render(this.host, items, strings.NavigationLabel); }
} catch (error) {
this.debug(strings.LoadError, error);
}
}
private cacheMinutes(): number {
const value: number = Number(this.properties && this.properties.cacheMinutes);
return isFinite(value) ? Math.min(Math.max(Math.floor(value), 1), 1440) : 15;
}
private maxDepth(): number {
const value: number = Number(this.properties && this.properties.maxDepth);
return isFinite(value) ? Math.min(Math.max(Math.floor(value), 1), 8) : 3;
}
private cacheVersion(): string {
const value: string = this.properties && this.properties.cacheVersion
? String(this.properties.cacheVersion).trim() : '1';
return value || '1';
}
private languageLcid(): number {
const legacy: any = (this.context.pageContext as any).legacyPageContext; // tslint:disable-line:no-any
const value: number = legacy ? Number(legacy.currentLanguage) : 0;
return isFinite(value) && value > 0 ? value : 1033;
}
private isDebug(): boolean { return !!(this.properties && this.properties.debug === true); }
private debug(message: string, data?: any): void { // tslint:disable-line:no-any
if (this.isDebug() && window.console) { console.log('[CurrentNavigation] ' + message, data || ''); }
}
private onPlaceholderDisposed = (): void => {
if (this.host) { this.renderer.clear(this.host); }
this.host = undefined;
this.topPlaceholder = undefined;
}
@override
protected onDispose(): void {
this.context.placeholderProvider.changedEvent.remove(this, this.renderPlaceholders);
const application: any = this.context.application as any; // tslint:disable-line:no-any
if (application && application.navigatedEvent) { application.navigatedEvent.remove(this, this.onNavigated); }
this.onPlaceholderDisposed();
}
}
@@ -0,0 +1,41 @@
import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes';
import { normalizeNavigationPath } from '../../services/CurrentNavigationCore';
export class CurrentNavigationRenderer {
public render(container: HTMLElement, items: ICurrentNavigationItem[], navigationLabel: string): void {
this.clear(container);
const nav: HTMLElement = document.createElement('nav');
nav.className = 'current-navigation';
nav.setAttribute('aria-label', navigationLabel);
nav.appendChild(this.createList(items, 1));
container.appendChild(nav);
}
public clear(container: HTMLElement): void {
while (container && container.firstChild) { container.removeChild(container.firstChild); }
}
private createList(items: ICurrentNavigationItem[], depth: number): HTMLUListElement {
const list: HTMLUListElement = document.createElement('ul');
list.className = depth === 1 ? 'current-navigation-root' : 'current-navigation-children';
for (let i: number = 0; i < items.length; i++) {
const item: ICurrentNavigationItem = items[i];
const entry: HTMLLIElement = document.createElement('li');
entry.className = item.items.length ? 'has-children' : '';
const content: HTMLElement = item.url ? document.createElement('a') : document.createElement('span');
content.textContent = item.label;
if (item.url) {
const link: HTMLAnchorElement = content as HTMLAnchorElement;
link.href = item.url;
if (normalizeNavigationPath(window.location.href) === normalizeNavigationPath(item.url)) {
link.setAttribute('aria-current', 'page');
link.className = 'is-current';
}
}
entry.appendChild(content);
if (item.items.length) { entry.appendChild(this.createList(item.items, depth + 1)); }
list.appendChild(entry);
}
return list;
}
}
@@ -0,0 +1,8 @@
define([], function() {
return {
"Title": "CurrentNavigationApplicationCustomizer",
"NavigationLabel": "Navigation der aktuellen Website",
"ConfigurationMissing": "Die aktuelle Navigation ist nicht konfiguriert.",
"LoadError": "Die aktuelle Navigation konnte nicht geladen werden."
};
});
@@ -0,0 +1,8 @@
define([], function() {
return {
"Title": "CurrentNavigationApplicationCustomizer",
"NavigationLabel": "Current site navigation",
"ConfigurationMissing": "Current navigation is not configured.",
"LoadError": "Current navigation could not be loaded."
};
});
+11
View File
@@ -0,0 +1,11 @@
declare interface ICurrentNavigationApplicationCustomizerStrings {
Title: string;
NavigationLabel: string;
ConfigurationMissing: string;
LoadError: string;
}
declare module 'CurrentNavigationApplicationCustomizerStrings' {
const myStrings: ICurrentNavigationApplicationCustomizerStrings;
export = myStrings;
}
+1
View File
@@ -0,0 +1 @@
// Entry point intentionally empty. The Application Customizer is registered through its manifest.
+139
View File
@@ -0,0 +1,139 @@
import { ICurrentNavigationItem, ICurrentNavigationTerm } from './CurrentNavigationTypes';
export const CurrentNavigationProperties = {
root: 'CurrentNavigation.Root',
hidden: 'CurrentNavigation.Hidden',
simpleLinkUrl: '_Sys_Nav_SimpleLinkUrl',
targetUrl: '_Sys_Nav_TargetUrl'
};
export function getLocalProperty(term: ICurrentNavigationTerm, name: string): string {
const properties: { [key: string]: any } = term && term.LocalCustomProperties || {}; // tslint:disable-line:no-any
const value: any = properties[name]; // tslint:disable-line:no-any
return value === undefined || value === null ? '' : String(value).trim();
}
export function parseBoolean(value: any): boolean { // tslint:disable-line:no-any
if (value === true || value === 1) { return true; }
const normalized: string = String(value || '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'ja';
}
export function resolveTermUrl(term: ICurrentNavigationTerm, siteCollectionUrl: string): string | undefined {
const raw: string = getLocalProperty(term, CurrentNavigationProperties.simpleLinkUrl)
|| getLocalProperty(term, CurrentNavigationProperties.targetUrl);
if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) { return undefined; }
const resolved: string = raw.toLowerCase().indexOf('~sitecollection') === 0
? String(siteCollectionUrl || '').replace(/\/+$/, '') + raw.substring('~sitecollection'.length)
: raw;
const protocol: RegExpMatchArray = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
if (protocol && protocol[1].toLowerCase() !== 'http' && protocol[1].toLowerCase() !== 'https') {
return undefined;
}
return resolved;
}
export function normalizeNavigationPath(value: string): string {
let path: string = String(value || '').trim().toLowerCase();
path = path.replace(/^https?:\/\/[^/]+/i, '').split('#')[0].split('?')[0].replace(/\/+$/, '');
return path || '/';
}
export function selectCurrentNavigationRoot(
terms: ICurrentNavigationTerm[],
currentWebUrl: string,
siteCollectionUrl: string
): ICurrentNavigationTerm | undefined {
const webPath: string = normalizeNavigationPath(currentWebUrl);
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);
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;
}
if (score > selectedScore) { selected = terms[i]; selectedScore = score; }
}
return selected;
}
export function buildCurrentNavigationItems(
terms: ICurrentNavigationTerm[],
root: ICurrentNavigationTerm,
siteCollectionUrl: string,
languageLcid: number,
maxDepth: number,
showRoot: boolean
): ICurrentNavigationItem[] {
const rootPath: string = String(root.PathOfTerm || '');
const rootDepth: number = pathDepth(rootPath);
const hiddenPaths: string[] = terms.filter((term: ICurrentNavigationTerm): boolean =>
parseBoolean(getLocalProperty(term, CurrentNavigationProperties.hidden))
).map((term: ICurrentNavigationTerm): string => String(term.PathOfTerm || '').toLowerCase());
const candidates: ICurrentNavigationTerm[] = terms.filter((term: ICurrentNavigationTerm): boolean => {
const path: string = String(term.PathOfTerm || '');
const isRoot: boolean = term.Id.toLowerCase() === root.Id.toLowerCase();
const isDescendant: boolean = path.toLowerCase().indexOf(rootPath.toLowerCase() + ';') === 0;
if ((!showRoot && isRoot) || (!isRoot && !isDescendant)) { return false; }
const relativeDepth: number = pathDepth(path) - rootDepth + (showRoot ? 1 : 0);
if (relativeDepth < 1 || relativeDepth > maxDepth) { return false; }
const lowerPath: string = path.toLowerCase();
for (let i: number = 0; i < hiddenPaths.length; i++) {
if (lowerPath === hiddenPaths[i] || lowerPath.indexOf(hiddenPaths[i] + ';') === 0) { return false; }
}
return true;
});
candidates.sort((a: ICurrentNavigationTerm, b: ICurrentNavigationTerm): number => {
const depthDifference: number = pathDepth(a.PathOfTerm) - pathDepth(b.PathOfTerm);
return depthDifference || compareTerms(a, b);
});
const byPath: { [path: string]: ICurrentNavigationItem } = {};
const result: ICurrentNavigationItem[] = [];
for (let i: number = 0; i < candidates.length; i++) {
const term: ICurrentNavigationTerm = candidates[i];
const item: ICurrentNavigationItem = {
id: cleanGuid(term.Id), label: localizedLabel(term, languageLcid),
url: resolveTermUrl(term, siteCollectionUrl), items: []
};
byPath[String(term.PathOfTerm || '').toLowerCase()] = item;
const parent: ICurrentNavigationItem = byPath[getParentPath(term.PathOfTerm).toLowerCase()];
if (parent) { parent.items.push(item); } else { result.push(item); }
}
return result;
}
export function cleanGuid(value: string): string {
const match: RegExpMatchArray = String(value || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
return match ? match[0] : String(value || '');
}
function localizedLabel(term: ICurrentNavigationTerm, languageLcid: number): string {
const labels = term.Labels && term.Labels._Child_Items_ ? term.Labels._Child_Items_ : [];
let fallback: string = term.Name || '';
for (let i: number = 0; i < labels.length; i++) {
if (Number(labels[i].Language) === languageLcid && labels[i].Value) { return labels[i].Value; }
if (labels[i].IsDefaultForLanguage && labels[i].Value) { fallback = labels[i].Value; }
}
return fallback;
}
function pathDepth(path: string): number { return path ? path.split(';').length : 1; }
function getParentPath(path: string): string {
const parts: string[] = String(path || '').split(';'); parts.pop(); return parts.join(';');
}
function compareTerms(a: ICurrentNavigationTerm, b: ICurrentNavigationTerm): number {
const ai: number = typeof a.CustomSortOrderIndex === 'number' ? a.CustomSortOrderIndex : -1;
const bi: number = typeof b.CustomSortOrderIndex === 'number' ? b.CustomSortOrderIndex : -1;
if (ai >= 0 || bi >= 0) { if (ai < 0) { return 1; } if (bi < 0) { return -1; } return ai - bi; }
const ap: string = String(a.PathOfTerm || '').toLowerCase();
const bp: string = String(b.PathOfTerm || '').toLowerCase();
return ap < bp ? -1 : (ap > bp ? 1 : 0);
}
@@ -0,0 +1,100 @@
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http';
import { cleanGuid } from './CurrentNavigationCore';
import { ICurrentNavigationTerm, ITermSetResult } from './CurrentNavigationTypes';
interface ICacheEntry { expiresAt: number; value: ITermSetResult; }
export class CurrentNavigationTaxonomyService {
constructor(
private context: ApplicationCustomizerContext,
private termSetId: string,
private cacheMinutes: number,
private cacheVersion: string,
private languageLcid: number,
private debug: boolean
) { }
public async getTerms(): Promise<ITermSetResult> {
const cached: ITermSetResult = this.readCache(false);
const stale: ITermSetResult = this.readCache(true);
if (cached) { return cached; }
try {
const result: ITermSetResult = await this.loadTerms();
this.writeCache(result);
return result;
} catch (error) {
if (stale) { this.log('Taxonomy request failed; using stale cache.', error); return stale; }
throw error;
}
}
private async loadTerms(): Promise<ITermSetResult> {
const id: string = cleanGuid(this.termSetId);
if (!/^[0-9a-f-]{36}$/i.test(id)) { throw new Error('A valid termSetId is required.'); }
const data: string = `<Request xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName="Javascript Library"><Actions><ObjectPath Id="1" ObjectPathId="0" /><ObjectIdentityQuery Id="2" ObjectPathId="0" /><ObjectPath Id="4" ObjectPathId="3" /><ObjectIdentityQuery Id="5" ObjectPathId="3" /><ObjectPath Id="7" ObjectPathId="6" /><ObjectIdentityQuery Id="8" ObjectPathId="6" /><ObjectPath Id="10" ObjectPathId="9" /><Query Id="11" ObjectPathId="6"><Query SelectAllProperties="true"><Properties /></Query></Query><Query Id="12" ObjectPathId="9"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="IsRoot" SelectAll="true" /><Property Name="Labels" SelectAll="true"><Query SelectAllProperties="false"><Properties /></Query></Property><Property Name="CustomSortOrder" SelectAll="true" /><Property Name="Id" SelectAll="true" /><Property Name="Name" SelectAll="true" /><Property Name="PathOfTerm" SelectAll="true" /><Property Name="Parent" SelectAll="true" /><Property Name="LocalCustomProperties" SelectAll="true" /><Property Name="IsDeprecated" ScalarProperty="true" /><Property Name="IsAvailableForTagging" ScalarProperty="true" /></Properties></ChildItemQuery></Query></Actions><ObjectPaths><StaticMethod Id="0" Name="GetTaxonomySession" TypeId="{981cbc68-9edc-4f8d-872f-71146fcbb84f}" /><Method Id="3" ParentId="0" Name="GetDefaultSiteCollectionTermStore" /><Method Id="6" ParentId="3" Name="GetTermSet"><Parameters><Parameter Type="Guid">${id}</Parameter></Parameters></Method><Method Id="9" ParentId="6" Name="GetAllTerms" /></ObjectPaths></Request>`;
const headers: Headers = new Headers();
headers.append('accept', 'application/json');
headers.append('content-type', 'application/xml');
const options: ISPHttpClientOptions = { headers: headers, body: data };
const url: string = this.context.pageContext.site.absoluteUrl.replace(/\/+$/, '')
+ '/_vti_bin/client.svc/ProcessQuery';
const response: SPHttpClientResponse = await this.context.spHttpClient.post(
url, SPHttpClient.configurations.v1, options
);
if (!response.ok) { throw new Error('Taxonomy request failed with HTTP ' + response.status + '.'); }
const json: any[] = await response.json(); // tslint:disable-line:no-any
if (!Array.isArray(json)) { throw new Error('Unexpected taxonomy response.'); }
const sets: any[] = json.filter((entry: any): boolean => entry._ObjectType_ === 'SP.Taxonomy.TermSet'); // tslint:disable-line:no-any
const collections: any[] = json.filter((entry: any): boolean => entry._ObjectType_ === 'SP.Taxonomy.TermCollection'); // tslint:disable-line:no-any
if (!sets.length || !collections.length) { throw new Error('The configured term set was not found.'); }
const set: any = sets[0]; // tslint:disable-line:no-any
let terms: ICurrentNavigationTerm[] = collections[0]._Child_Items_ || [];
terms = terms.filter((term: ICurrentNavigationTerm): boolean =>
term.IsDeprecated !== true && term.IsAvailableForTagging !== false
).map((term: ICurrentNavigationTerm): ICurrentNavigationTerm => {
term.Id = cleanGuid(term.Id);
if (term.Parent) {
term.ParentId = cleanGuid(term.Parent.Id);
term.CustomSortOrderIndex = term.Parent.CustomSortOrder
? term.Parent.CustomSortOrder.split(':').indexOf(term.Id) : -1;
} else {
term.CustomSortOrderIndex = set.CustomSortOrder
? set.CustomSortOrder.split(':').indexOf(term.Id) : -1;
}
return term;
});
return { Id: cleanGuid(set.Id), Name: set.Name || '', CustomSortOrder: set.CustomSortOrder, Terms: terms };
}
private cacheKey(): string {
const normalize = (value: string): string => encodeURIComponent(String(value || '').trim().toLowerCase());
return 'CurrentNavigation:TermSet:v1:' + normalize(this.context.pageContext.site.absoluteUrl) + ':'
+ normalize(this.termSetId) + ':' + this.languageLcid + ':' + normalize(this.cacheVersion || '1');
}
private readCache(allowExpired: boolean): ITermSetResult | undefined {
try {
const serialized: string = window.sessionStorage ? window.sessionStorage.getItem(this.cacheKey()) : '';
if (!serialized) { return undefined; }
const entry: ICacheEntry = JSON.parse(serialized) as ICacheEntry;
return entry && entry.value && (allowExpired || entry.expiresAt > Date.now()) ? entry.value : undefined;
} catch (error) { return undefined; }
}
private writeCache(value: ITermSetResult): void {
try {
if (window.sessionStorage) {
const entry: ICacheEntry = {
expiresAt: Date.now() + this.cacheMinutes * 60 * 1000,
value: value
};
window.sessionStorage.setItem(this.cacheKey(), JSON.stringify(entry));
}
} catch (error) { this.log('Term cache could not be written.', error); }
}
private log(message: string, data?: any): void { // tslint:disable-line:no-any
if (this.debug && window.console) { console.log('[CurrentNavigation] ' + message, data || ''); }
}
}
+28
View File
@@ -0,0 +1,28 @@
export interface ICurrentNavigationTerm {
_ObjectType_?: string;
Id: string;
Name: string;
PathOfTerm: string;
IsDeprecated?: boolean;
IsAvailableForTagging?: boolean;
IsRoot?: boolean;
Parent?: { Id: string; CustomSortOrder?: string };
ParentId?: string;
CustomSortOrderIndex?: number;
Labels?: { _Child_Items_: Array<{ Language: number; Value: string; IsDefaultForLanguage?: boolean }> };
LocalCustomProperties?: { [key: string]: any }; // tslint:disable-line:no-any
}
export interface ICurrentNavigationItem {
id: string;
label: string;
url?: string;
items: ICurrentNavigationItem[];
}
export interface ITermSetResult {
Id: string;
Name: string;
CustomSortOrder?: string;
Terms: ICurrentNavigationTerm[];
}
+47
View File
@@ -0,0 +1,47 @@
import {
buildCurrentNavigationItems, getLocalProperty, parseBoolean, resolveTermUrl, selectCurrentNavigationRoot
} from '../src/services/CurrentNavigationCore';
import { ICurrentNavigationTerm } from '../src/services/CurrentNavigationTypes';
let passed: number = 0;
function assert(name: string, condition: boolean): void {
if (!condition) { throw new Error('FAILED: ' + name); }
passed++; console.log('OK:', name);
}
function term(value: any): ICurrentNavigationTerm { return value as ICurrentNavigationTerm; } // tslint:disable-line:no-any
const terms: ICurrentNavigationTerm[] = [
term({ Id: 'root', Name: 'HR', PathOfTerm: 'HR', LocalCustomProperties: {
'CurrentNavigation.Root': 'true', '_Sys_Nav_SimpleLinkUrl': '/sites/portal/hr/SitePages/Home.aspx'
}}),
term({ Id: 'docs', Name: 'Dokumente', PathOfTerm: 'HR;Dokumente', LocalCustomProperties: {
'_Sys_Nav_SimpleLinkUrl': '/sites/portal/hr/Dokumente'
}}),
term({ Id: 'admin', Name: 'Administration', PathOfTerm: 'HR;Administration', LocalCustomProperties: {
'CurrentNavigation.Hidden': 'true'
}}),
term({ Id: 'secret', Name: 'Intern', PathOfTerm: 'HR;Administration;Intern', LocalCustomProperties: {} }),
term({ Id: 'forms', Name: 'Formulare', PathOfTerm: 'HR;Dokumente;Formulare', LocalCustomProperties: {} })
];
assert('German boolean is accepted', parseBoolean('ja'));
assert('local property is read', getLocalProperty(terms[0], 'CurrentNavigation.Root') === 'true');
assert('site collection token resolves', resolveTermUrl(term({
LocalCustomProperties: { '_Sys_Nav_SimpleLinkUrl': '~sitecollection/start' }
}), 'https://portal/sites/a') === 'https://portal/sites/a/start');
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');
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');
assert('hidden branch is removed', items.every(item => item.id !== 'admin'));
const withRoot = buildCurrentNavigationItems(terms, root, 'https://portal/sites/portal', 1031, 3, true);
assert('root can be rendered', withRoot.length === 1 && withRoot[0].id === 'root');
const unordered = buildCurrentNavigationItems(
[terms[4], terms[1], terms[0]], root, 'https://portal/sites/portal', 1031, 3, false
);
assert('hierarchy is independent of taxonomy response order',
unordered.length === 1 && unordered[0].id === 'docs' && unordered[0].items[0].id === 'forms');
console.log('CurrentNavigation core tests passed:', passed);
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es5", "module": "commonjs", "moduleResolution": "node",
"outDir": "../temp/tests", "rootDir": "..", "skipLibCheck": true,
"types": [], "lib": ["es5", "dom", "es2015.collection"]
},
"files": [
"../src/services/CurrentNavigationTypes.ts",
"../src/services/CurrentNavigationCore.ts",
"CurrentNavigationCore.test.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
'use strict';
var fs = require('fs');
function read(path) { return fs.readFileSync(path, 'utf8'); }
function json(path) { return JSON.parse(read(path)); }
function assert(name, condition) { if (!condition) { throw new Error('FAILED: ' + name); } console.log('OK:', name); }
var packageJson = json('package.json');
var solution = json('config/package-solution.json').solution;
var manifest = json('src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.manifest.json');
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.0.0');
assert('solution version', solution.version === '1.0.0.0');
assert('tenant deployment', solution.skipFeatureDeployment === true);
assert('component type', manifest.extensionType === 'ApplicationCustomizer');
assert('safe renderer', renderer.indexOf('innerHTML') < 0 && app.indexOf('innerHTML') < 0);
assert('placeholder lifecycle cleanup', app.indexOf('changedEvent.remove') >= 0);
assert('local properties loaded', service.indexOf('LocalCustomProperties') >= 0);
assert('root property present', read('src/services/CurrentNavigationCore.ts').indexOf('CurrentNavigation.Root') >= 0);
assert('hidden property present', read('src/services/CurrentNavigationCore.ts').indexOf('CurrentNavigation.Hidden') >= 0);
console.log('CurrentNavigation static tests passed.');
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es5", "forceConsistentCasingInFileNames": true, "module": "esnext",
"moduleResolution": "node", "declaration": true, "sourceMap": true,
"experimentalDecorators": true, "skipLibCheck": true,
"typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"],
"types": ["es6-promise", "webpack-env"],
"lib": ["es5", "dom", "es2015.collection"]
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false, "export-name": false, "member-access": true,
"no-console": false, "no-duplicate-variable": true, "no-shadowed-variable": true,
"semicolon": true, "typedef": false, "variable-name": false, "whitespace": false
}
}