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:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
lib/
|
||||
temp/
|
||||
dist/
|
||||
sharepoint/solution/debug/
|
||||
sharepoint/solution/*.sppkg
|
||||
npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||
"deployCdnPath": "temp/deploy"
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||
}
|
||||
@@ -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() } }
|
||||
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
const build = require('@microsoft/sp-build-web');
|
||||
build.initialize(require('gulp'));
|
||||
Generated
+17534
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
declare interface ICurrentNavigationApplicationCustomizerStrings {
|
||||
Title: string;
|
||||
NavigationLabel: string;
|
||||
ConfigurationMissing: string;
|
||||
LoadError: string;
|
||||
}
|
||||
|
||||
declare module 'CurrentNavigationApplicationCustomizerStrings' {
|
||||
const myStrings: ICurrentNavigationApplicationCustomizerStrings;
|
||||
export = myStrings;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Entry point intentionally empty. The Application Customizer is registered through its manifest.
|
||||
@@ -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 || ''); }
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.');
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user