initial Commit

This commit is contained in:
Torsten Brendgen
2026-08-24 12:51:11 +02:00
commit d354e757bf
28 changed files with 759 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
node_modules/
lib/
dist/
temp/
release/
solution/
sharepoint/solution/
*.sppkg
*.tgz
coverage/
*.resx.ts
*.scss.ts
*.log
npm-debug.log*
.vs/
.idea/
.DS_Store
Thumbs.db
+11
View File
@@ -0,0 +1,11 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.4.1",
"libraryName": "modern-promoted-link-list-webpart",
"libraryId": "26608df8-0234-40ac-8f8e-d0679aa93fc3",
"environment": "onprem",
"packageManager": "npm",
"solutionName": "modern-promoted-link-list-webpart",
"componentType": "webpart"
}
}
+38
View File
@@ -0,0 +1,38 @@
# Modern Promoted Link List
SPFx-1.4.1-WebPart für SharePoint Server Subscription Edition. Es stellt eine klassische Liste vom Typ **Höher gestufte Links / Promoted Links** als responsive Kacheln auf modernen Seiten dar.
## Funktionen
- Auswahl vorhandener Promoted-Links-Listen im Property Pane
- Übernahme von Titel, Beschreibung, Hintergrundbild, Ziel, Startverhalten und Reihenfolge
- einstellbare maximale Spaltenanzahl (1–6), Kachelhöhe (120–320 px) und Beschreibungsanzeige
- responsive Darstellung für schmale Bereiche und mobile Geräte
- Unterstützung für Linköffnung auf derselben Seite, in einem neuen Tab/Fenster oder in einem Dialogfenster
- SharePoint-Themefarben, Tastaturfokus und zugängliche Beschriftungen
- Prüfung der Ziel- und Bild-URLs gegen unsichere Schemas
## Verwendung
1. Falls noch nicht vorhanden, über **Websiteinhalte > App hinzufügen** eine Liste vom Typ **Höher gestufte Links** erstellen.
2. Einträge in der klassischen Liste pflegen.
3. Das WebPart **Modern Promoted Link List** auf einer modernen Seite hinzufügen.
4. Im Property Pane die gewünschte Liste und das Layout auswählen.
Die Liste muss sich im selben Web wie die Seite befinden. Einträge ohne gültiges Linkziel werden nicht dargestellt.
## Build-Umgebung
- Node.js 8.17.0
- npm 6.13.4
- SharePoint Framework 1.4.1
```powershell
npm install
npm test
gulp clean
gulp bundle --ship
gulp package-solution --ship
```
Das Paket wird unter `sharepoint/solution/modern-promoted-link-list-webpart.sppkg` erzeugt.
+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": {
"modern-promoted-link-list-webpart": {
"components": [{
"entrypoint": "./lib/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.js",
"manifest": "./src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.manifest.json"
}]
}
},
"externals": {},
"localizedResources": {
"ModernPromotedLinksWebPartStrings": "lib/webparts/modernPromotedLinks/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"
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "modern-promoted-link-list-webpart",
"title": "modern-promoted-link-list-webpart",
"id": "26608df8-0234-40ac-8f8e-d0679aa93fc3",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
"paths": {
"zippedPackage": "solution/modern-promoted-link-list-webpart.sppkg"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}
+3
View File
@@ -0,0 +1,3 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.initialize(require('gulp'));
+33
View File
@@ -0,0 +1,33 @@
{
"name": "modern-promoted-link-list-webpart",
"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 --noEmit -p . && tsc -p tests/tsconfig.json && node temp/tests/tests/PromotedLinkCore.test.js && node tests/validate-project.js",
"package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship"
},
"dependencies": {
"@microsoft/sp-core-library": "1.4.1",
"@microsoft/sp-http": "1.4.1",
"@microsoft/sp-office-ui-fabric-core": "1.4.1",
"@microsoft/sp-webpart-base": "1.4.1",
"@types/es6-promise": "0.0.33",
"@types/react": "15.6.6",
"@types/react-dom": "15.5.6",
"@types/webpack-env": "1.13.1",
"react": "15.6.2",
"react-dom": "15.6.2"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.4.1",
"@microsoft/sp-module-interfaces": "1.4.1",
"@microsoft/sp-tslint-rules": "1.4.1",
"@microsoft/sp-webpart-workbench": "1.4.1",
"ajv": "5.2.2",
"gulp": "~3.9.1"
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.module.scss' {
const styles: { [className: string]: string };
export = styles;
}
+1
View File
@@ -0,0 +1 @@
export { default as ModernPromotedLinksWebPart } from './webparts/modernPromotedLinks/ModernPromotedLinksWebPart';
@@ -0,0 +1,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "f88df9e2-9e60-4e48-acb2-bd2898c5e75b",
"alias": "ModernPromotedLinksWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other", "de-de": "Weitere" },
"title": { "default": "Modern Promoted Link List", "de-de": "Moderne Linkkacheln" },
"description": {
"default": "Displays a classic Promoted Links list as responsive modern tiles.",
"de-de": "Zeigt eine klassische Liste 'Höher gestufte Links' als responsive moderne Kacheln an."
},
"officeFabricIconFontName": "Tiles",
"properties": {
"listId": "",
"columns": 4,
"tileHeight": 180,
"showDescriptions": true
}
}]
}
@@ -0,0 +1,129 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
IPropertyPaneDropdownOption,
PropertyPaneDropdown,
PropertyPaneSlider,
PropertyPaneToggle
} from '@microsoft/sp-webpart-base';
import * as strings from 'ModernPromotedLinksWebPartStrings';
import { normalizeColumns, normalizeTileHeight } from './PromotedLinkCore';
import { PromotedLinksDataService } from './PromotedLinksDataService';
import { IPromotedLinkItem, IPromotedLinksListOption } from './PromotedLinkTypes';
import ModernPromotedLinks from './components/ModernPromotedLinks';
import { IModernPromotedLinksProps } from './components/IModernPromotedLinksProps';
import styles = require('./components/ModernPromotedLinks.module.scss');
export interface IModernPromotedLinksWebPartProps {
listId: string;
columns: number;
tileHeight: number;
showDescriptions: boolean;
}
export default class ModernPromotedLinksWebPart extends BaseClientSideWebPart<IModernPromotedLinksWebPartProps> {
private service: PromotedLinksDataService;
private lists: IPromotedLinksListOption[] = [];
private loadingLists: boolean = false;
private requestVersion: number = 0;
private disposed: boolean = false;
protected onInit(): Promise<void> {
this.service = new PromotedLinksDataService(this.context);
return super.onInit().then((): Promise<void> => this.loadLists());
}
public render(): void {
const version: number = ++this.requestVersion;
if (!this.properties.listId) { this.renderStatus(strings.ConfigureMessage, false); return; }
this.renderStatus(strings.LoadingMessage, false);
this.service.getItems(this.properties.listId).then((items: IPromotedLinkItem[]): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (!items.length) { this.renderStatus(strings.EmptyMessage, false); return; }
const element: React.ReactElement<IModernPromotedLinksProps> = React.createElement(ModernPromotedLinks, {
items: items,
columns: normalizeColumns(this.properties.columns),
tileHeight: normalizeTileHeight(this.properties.tileHeight),
showDescriptions: this.properties.showDescriptions !== false,
dialogWindowLabel: strings.DialogWindowLabel
});
ReactDom.render(element, this.domElement);
}).catch((error: Error): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (window.console) { console.error('[ModernPromotedLinks] ' + strings.LoadErrorMessage, error); }
this.renderStatus(strings.LoadErrorMessage, true);
});
}
protected onDispose(): void {
this.disposed = true;
this.requestVersion++;
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version { return Version.parse('1.0'); }
protected onPropertyPaneConfigurationStart(): void {
if (!this.lists.length && !this.loadingLists) { this.loadLists(); }
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const listOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectOption }];
for (let i: number = 0; i < this.lists.length; i++) {
listOptions.push({ key: this.lists[i].id, text: this.lists[i].title });
}
return {
pages: [{
header: { description: strings.PropertyPaneDescription },
groups: [{
groupName: strings.DataGroupName,
groupFields: [
PropertyPaneDropdown('listId', {
label: strings.ListFieldLabel,
options: listOptions,
disabled: this.loadingLists
})
]
}, {
groupName: strings.LayoutGroupName,
groupFields: [
PropertyPaneSlider('columns', { label: strings.ColumnsFieldLabel, min: 1, max: 6, step: 1 }),
PropertyPaneSlider('tileHeight', { label: strings.TileHeightFieldLabel, min: 120, max: 320, step: 10 }),
PropertyPaneToggle('showDescriptions', { label: strings.ShowDescriptionsFieldLabel })
]
}]
}]
};
}
private loadLists(): Promise<void> {
this.loadingLists = true;
this.refreshPropertyPane();
return this.service.getLists().then((lists: IPromotedLinksListOption[]): void => {
this.lists = lists;
}).catch((error: Error): void => {
this.lists = [];
if (window.console) { console.error('[ModernPromotedLinks] Lists could not be loaded.', error); }
}).then((): void => {
this.loadingLists = false;
this.refreshPropertyPane();
});
}
private refreshPropertyPane(): void {
if (this.context && this.context.propertyPane) { this.context.propertyPane.refresh(); }
}
private renderStatus(message: string, error: boolean): void {
ReactDom.unmountComponentAtNode(this.domElement);
const status: HTMLDivElement = document.createElement('div');
status.className = error ? styles.error : styles.status;
status.setAttribute('role', error ? 'alert' : 'status');
status.textContent = message;
this.domElement.innerHTML = '';
this.domElement.appendChild(status);
}
}
@@ -0,0 +1,38 @@
export function normalizeColumns(value: number): number {
const result: number = Math.round(Number(value) || 4);
return Math.min(6, Math.max(1, result));
}
export function normalizeTileHeight(value: number): number {
const result: number = Math.round(Number(value) || 180);
return Math.min(320, Math.max(120, result));
}
export function normalizeNavigationUrl(value: string, webUrl: string): string {
return normalizeUrl(value, webUrl, true);
}
export function normalizeImageUrl(value: string, webUrl: string): string {
return normalizeUrl(value, webUrl, false);
}
export function isNewTabLaunch(value: string): boolean {
return /^new\s*(tab|window)$/i.test(String(value || '').trim());
}
export function isDialogLaunch(value: string): boolean {
return /^dialog$/i.test(String(value || '').trim());
}
function normalizeUrl(value: string, webUrl: string, allowSpecialLinks: boolean): string {
const url: string = String(value || '').trim();
if (!url || /[\u0000-\u001f\u007f]/.test(url) || /^\/\//.test(url)) { return ''; }
if (/^https?:\/\//i.test(url)) { return url; }
if (allowSpecialLinks && /^(mailto|tel):/i.test(url)) { return url; }
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) { return ''; }
if (url.charAt(0) === '/' || (allowSpecialLinks && (url.charAt(0) === '#' || url.charAt(0) === '?'))) {
return url;
}
const base: string = String(webUrl || '').replace(/\/$/, '');
return base ? base + '/' + url.replace(/^\.\//, '') : '';
}
@@ -0,0 +1,14 @@
export interface IPromotedLinksListOption {
id: string;
title: string;
}
export interface IPromotedLinkItem {
id: number;
title: string;
description: string;
linkUrl: string;
imageUrl: string;
launchBehavior: string;
tileOrder: number;
}
@@ -0,0 +1,59 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { normalizeImageUrl, normalizeNavigationUrl } from './PromotedLinkCore';
import { IPromotedLinkItem, IPromotedLinksListOption } from './PromotedLinkTypes';
export class PromotedLinksDataService {
public constructor(private context: WebPartContext) { }
public getLists(): Promise<IPromotedLinksListOption[]> {
const url: string = this.webUrl +
'/_api/web/lists?$select=Id,Title,BaseTemplate&$filter=Hidden eq false and BaseTemplate eq 170&$orderby=Title';
return this.getJson(url).then((payload: any): IPromotedLinksListOption[] => // tslint:disable-line:no-any
this.results(payload).map((list: any): IPromotedLinksListOption => ({ // tslint:disable-line:no-any
id: this.cleanGuid(list.Id),
title: String(list.Title || list.Id)
})));
}
public getItems(listId: string): Promise<IPromotedLinkItem[]> {
if (!this.isGuid(listId)) { return Promise.reject(new Error('Invalid list ID.')); }
const fields: string = 'Id,Title,Description,BackgroundImageLocation,LinkLocation,LaunchBehavior,TileOrder';
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + "')/items" +
'?$select=' + fields + '&$orderby=TileOrder asc,Title asc&$top=5000';
return this.getJson(url).then((payload: any): IPromotedLinkItem[] => // tslint:disable-line:no-any
this.results(payload).map((item: any): IPromotedLinkItem => ({ // tslint:disable-line:no-any
id: Number(item.Id),
title: String(item.Title || ''),
description: String(item.Description || ''),
linkUrl: normalizeNavigationUrl(this.urlValue(item.LinkLocation), this.webUrl),
imageUrl: normalizeImageUrl(this.urlValue(item.BackgroundImageLocation), this.webUrl),
launchBehavior: String(item.LaunchBehavior || ''),
tileOrder: Number(item.TileOrder) || 0
})).filter((item: IPromotedLinkItem): boolean => !!item.linkUrl));
}
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
private get headers(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } // tslint:disable-line:no-any
private getJson(url: string): Promise<any> { // tslint:disable-line:no-any
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers })
.then((response: SPHttpClientResponse): Promise<any> => { // tslint:disable-line:no-any
if (response.ok) { return response.json(); }
return response.text().then((text: string): Promise<any> => // tslint:disable-line:no-any
Promise.reject(new Error('SharePoint request failed (' + response.status + '): ' + text)));
});
}
private results(payload: any): any[] { // tslint:disable-line:no-any
const source: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
return source && Array.isArray(source.results) ? source.results : (Array.isArray(source) ? source : []);
}
private urlValue(value: any): string { // tslint:disable-line:no-any
if (!value) { return ''; }
if (typeof value === 'string') { return value; }
return String(value.Url || value.url || '');
}
private isGuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(this.cleanGuid(value)); }
private cleanGuid(value: string): string { return String(value || '').replace(/[{}]/g, '').toLowerCase(); }
}
@@ -0,0 +1,9 @@
import { IPromotedLinkItem } from '../PromotedLinkTypes';
export interface IModernPromotedLinksProps {
items: IPromotedLinkItem[];
columns: number;
tileHeight: number;
showDescriptions: boolean;
dialogWindowLabel: string;
}
@@ -0,0 +1,88 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.modernPromotedLinks {
color: "[theme:bodyText, default:#323130]";
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
}
.grid {
display: grid;
gap: 16px;
list-style: none;
margin: 0;
padding: 0;
}
.columns1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.columns2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.columns3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.columns4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.columns5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.columns6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.tile {
background: "[theme:themePrimary, default:#0078d4]";
min-width: 0;
overflow: hidden;
}
.link {
background-color: "[theme:themePrimary, default:#0078d4]";
background-position: center;
background-repeat: no-repeat;
background-size: cover;
color: #fff;
display: block;
height: 100%;
overflow: hidden;
position: relative;
text-decoration: none;
transition: transform 120ms ease-out;
&:hover { color: #fff; transform: scale(1.015); }
&:focus { color: #fff; outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: -2px; }
}
.fallbackIcon {
font-size: 42px;
left: 20px;
opacity: .75;
position: absolute;
top: 14px;
}
.overlay {
background: linear-gradient(to bottom, rgba(0, 0, 0, .05), rgba(0, 0, 0, .86));
bottom: 0;
box-sizing: border-box;
display: block;
left: 0;
padding: 28px 16px 14px;
position: absolute;
right: 0;
}
.title { display: block; font-size: 18px; font-weight: 600; line-height: 24px; }
.description {
display: -webkit-box;
font-size: 13px;
line-height: 18px;
margin-top: 3px;
max-height: 36px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.status { color: "[theme:bodyText, default:#323130]"; padding: 16px; }
.error { color: "[theme:errorText, default:#a80000]"; padding: 16px; }
@media (max-width: 1024px) {
.columns4, .columns5, .columns6 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.columns3, .columns4, .columns5, .columns6 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 480px) {
.columns2, .columns3, .columns4, .columns5, .columns6 { grid-template-columns: minmax(0, 1fr); }
}
@@ -0,0 +1,51 @@
import * as React from 'react';
import { isDialogLaunch, isNewTabLaunch } from '../PromotedLinkCore';
import { IPromotedLinkItem } from '../PromotedLinkTypes';
import styles = require('./ModernPromotedLinks.module.scss');
import { IModernPromotedLinksProps } from './IModernPromotedLinksProps';
export default class ModernPromotedLinks extends React.Component<IModernPromotedLinksProps, {}> {
public render(): React.ReactElement<IModernPromotedLinksProps> {
const gridClass: string = styles.grid + ' ' + styles['columns' + this.props.columns];
return (
<div className={styles.modernPromotedLinks}>
<ul className={gridClass} role='list'>
{this.props.items.map((item: IPromotedLinkItem): JSX.Element => this.renderTile(item))}
</ul>
</div>
);
}
private renderTile(item: IPromotedLinkItem): JSX.Element {
const newTab: boolean = isNewTabLaunch(item.launchBehavior);
const dialog: boolean = isDialogLaunch(item.launchBehavior);
const imageStyle: React.CSSProperties = item.imageUrl ? { backgroundImage: 'url("' + this.cssUrl(item.imageUrl) + '")' } : {};
const label: string = item.description ? item.title + '. ' + item.description : item.title;
return (
<li key={item.id} className={styles.tile} style={{ height: this.props.tileHeight + 'px' }}>
<a className={styles.link} href={item.linkUrl} style={imageStyle} aria-label={label}
target={newTab ? '_blank' : undefined} rel={newTab ? 'noopener noreferrer' : undefined}
onClick={dialog ? (event: React.MouseEvent<HTMLAnchorElement>): void => this.openDialog(event, item) : undefined}>
{!item.imageUrl && <span className={styles.fallbackIcon} aria-hidden='true'>&#8599;</span>}
<span className={styles.overlay}>
<span className={styles.title}>{item.title || item.linkUrl}</span>
{this.props.showDescriptions && item.description && <span className={styles.description}>{item.description}</span>}
</span>
</a>
</li>
);
}
private openDialog(event: React.MouseEvent<HTMLAnchorElement>, item: IPromotedLinkItem): void {
event.preventDefault();
const width: number = Math.min(1100, Math.max(640, window.innerWidth - 80));
const height: number = Math.min(800, Math.max(480, window.innerHeight - 80));
const child: Window = window.open(item.linkUrl, this.props.dialogWindowLabel + item.id,
'width=' + width + ',height=' + height + ',resizable=yes,scrollbars=yes') as Window;
if (child) { child.opener = null; }
}
private cssUrl(value: string): string {
return String(value || '').replace(/["'()\\\r\n]/g, '');
}
}
@@ -0,0 +1,17 @@
define([], function() {
return {
PropertyPaneDescription: 'Wählen Sie eine klassische Liste "Höher gestufte Links" aus und konfigurieren Sie die Kacheln.',
DataGroupName: 'Datenquelle',
LayoutGroupName: 'Darstellung',
ListFieldLabel: 'Liste "Höher gestufte Links"',
ColumnsFieldLabel: 'Maximale Spaltenanzahl',
TileHeightFieldLabel: 'Kachelhöhe (px)',
ShowDescriptionsFieldLabel: 'Beschreibungen anzeigen',
SelectOption: 'Liste auswählen',
ConfigureMessage: 'Bearbeiten Sie das WebPart und wählen Sie eine Liste "Höher gestufte Links" aus.',
LoadingMessage: 'Links werden geladen…',
EmptyMessage: 'Die ausgewählte Liste enthält keine gültigen Links.',
LoadErrorMessage: 'Die Links konnten nicht geladen werden.',
DialogWindowLabel: 'PromotedLinkDialog'
};
});
@@ -0,0 +1,17 @@
define([], function() {
return {
PropertyPaneDescription: 'Select a classic Promoted Links list and configure the tile layout.',
DataGroupName: 'Data source',
LayoutGroupName: 'Layout',
ListFieldLabel: 'Promoted Links list',
ColumnsFieldLabel: 'Maximum columns',
TileHeightFieldLabel: 'Tile height (px)',
ShowDescriptionsFieldLabel: 'Show descriptions',
SelectOption: 'Select a list',
ConfigureMessage: 'Edit the web part and select a Promoted Links list.',
LoadingMessage: 'Loading promoted links…',
EmptyMessage: 'The selected list contains no valid promoted links.',
LoadErrorMessage: 'The promoted links could not be loaded.',
DialogWindowLabel: 'PromotedLinkDialog'
};
});
+20
View File
@@ -0,0 +1,20 @@
declare interface IModernPromotedLinksWebPartStrings {
PropertyPaneDescription: string;
DataGroupName: string;
LayoutGroupName: string;
ListFieldLabel: string;
ColumnsFieldLabel: string;
TileHeightFieldLabel: string;
ShowDescriptionsFieldLabel: string;
SelectOption: string;
ConfigureMessage: string;
LoadingMessage: string;
EmptyMessage: string;
LoadErrorMessage: string;
DialogWindowLabel: string;
}
declare module 'ModernPromotedLinksWebPartStrings' {
const strings: IModernPromotedLinksWebPartStrings;
export = strings;
}
+32
View File
@@ -0,0 +1,32 @@
import {
isDialogLaunch,
isNewTabLaunch,
normalizeColumns,
normalizeImageUrl,
normalizeNavigationUrl,
normalizeTileHeight
} from '../src/webparts/modernPromotedLinks/PromotedLinkCore';
declare const console: { log(message: string): void };
let passed: number = 0;
function assert(condition: boolean, message: string): void {
if (!condition) { throw new Error(message); }
passed++;
}
const webUrl: string = 'http://portal.contoso.local/sites/demo';
assert(normalizeNavigationUrl('Pages/Start.aspx', webUrl) === webUrl + '/Pages/Start.aspx', 'Relative links must be resolved.');
assert(normalizeNavigationUrl('/sites/demo', webUrl) === '/sites/demo', 'Server-relative links must be retained.');
assert(normalizeNavigationUrl('mailto:test@example.com', webUrl) === 'mailto:test@example.com', 'Mail links must be supported.');
assert(normalizeNavigationUrl('javascript:alert(1)', webUrl) === '', 'JavaScript URLs must be rejected.');
assert(normalizeNavigationUrl('data:text/html,test', webUrl) === '', 'Data URLs must be rejected.');
assert(normalizeImageUrl('mailto:test@example.com', webUrl) === '', 'Special navigation URLs must not be accepted as images.');
assert(normalizeImageUrl('/SiteAssets/tile.jpg', webUrl) === '/SiteAssets/tile.jpg', 'Server-relative image URLs must be retained.');
assert(isNewTabLaunch('New tab'), 'New tab launch behavior must be recognized.');
assert(isNewTabLaunch('New window'), 'New window launch behavior must be recognized.');
assert(isDialogLaunch('Dialog'), 'Dialog launch behavior must be recognized.');
assert(normalizeColumns(9) === 6 && normalizeColumns(0) === 4, 'Column values must be normalized.');
assert(normalizeTileHeight(80) === 120 && normalizeTileHeight(400) === 320, 'Tile height must be normalized.');
console.log('Promoted link core tests passed: ' + passed);
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"rootDir": "..",
"lib": ["es5", "dom", "es2015.promise"],
"types": [],
"skipLibCheck": true
},
"files": [
"../src/webparts/modernPromotedLinks/PromotedLinkCore.ts",
"PromotedLinkCore.test.ts"
]
}
+42
View File
@@ -0,0 +1,42 @@
'use strict';
var fs = require('fs');
var path = require('path');
var root = path.resolve(__dirname, '..');
var passed = 0;
function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); }
function json(file) { return JSON.parse(read(file)); }
function assert(condition, message) { if (!condition) { throw new Error(message); } passed++; }
var packageJson = json('package.json');
var packageSolution = json('config/package-solution.json');
var solution = packageSolution.solution;
var config = json('config/config.json');
var manifest = json('src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.manifest.json');
var webPart = read('src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.ts');
var service = read('src/webparts/modernPromotedLinks/PromotedLinksDataService.ts');
var component = read('src/webparts/modernPromotedLinks/components/ModernPromotedLinks.tsx');
var core = read('src/webparts/modernPromotedLinks/PromotedLinkCore.ts');
var styles = read('src/webparts/modernPromotedLinks/components/ModernPromotedLinks.module.scss');
assert(packageJson.name === 'modern-promoted-link-list-webpart', 'npm package name is inconsistent.');
assert(solution.name === packageJson.name && solution.title === packageJson.name, 'Solution naming is inconsistent.');
assert(packageSolution.paths.zippedPackage === 'solution/modern-promoted-link-list-webpart.sppkg', 'Package file name is inconsistent.');
assert(solution.includeClientSideAssets === true && solution.skipFeatureDeployment === true, 'Central asset deployment is not configured.');
assert(manifest.componentType === 'WebPart' && manifest.alias === 'ModernPromotedLinksWebPart', 'WebPart manifest is invalid.');
assert(!!config.bundles['modern-promoted-link-list-webpart'], 'Bundle name is inconsistent.');
assert(service.indexOf('BaseTemplate eq 170') >= 0, 'Promoted Links list filtering is missing.');
['BackgroundImageLocation', 'LinkLocation', 'LaunchBehavior', 'TileOrder'].forEach(function(field) {
assert(service.indexOf(field) >= 0, 'Classic Promoted Links field is missing: ' + field);
});
assert(webPart.indexOf("PropertyPaneDropdown('listId'") >= 0, 'List picker is missing.');
assert(webPart.indexOf("PropertyPaneSlider('columns'") >= 0 && webPart.indexOf("PropertyPaneSlider('tileHeight'") >= 0,
'Layout controls are missing.');
assert(component.indexOf("role='list'") >= 0 && component.indexOf('aria-label={label}') >= 0, 'Accessible list semantics are missing.');
assert(core.indexOf("/^[a-z][a-z0-9+.-]*:/i") >= 0, 'Unsafe URL schemes are not rejected.');
['bodyText', 'themePrimary', 'focusBorder', 'errorText'].forEach(function(slot) {
assert(styles.indexOf('[theme:' + slot + ', default:') >= 0, 'SharePoint theme slot is missing: ' + slot);
});
console.log('Modern Promoted Link List project validation passed: ' + passed);
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"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"]
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}