From d354e757bf079a3a8d37125328c939bb64032de0 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Mon, 24 Aug 2026 12:51:11 +0200 Subject: [PATCH] initial Commit --- .gitignore | 18 +++ .yo-rc.json | 11 ++ README.md | 38 ++++++ config/config.json | 16 +++ config/copy-assets.json | 4 + config/package-solution.json | 14 ++ config/serve.json | 10 ++ config/write-manifests.json | 4 + gulpfile.js | 3 + package.json | 33 +++++ src/declarations.d.ts | 4 + src/index.ts | 1 + .../ModernPromotedLinksWebPart.manifest.json | 25 ++++ .../ModernPromotedLinksWebPart.ts | 129 ++++++++++++++++++ .../modernPromotedLinks/PromotedLinkCore.ts | 38 ++++++ .../modernPromotedLinks/PromotedLinkTypes.ts | 14 ++ .../PromotedLinksDataService.ts | 59 ++++++++ .../components/IModernPromotedLinksProps.ts | 9 ++ .../ModernPromotedLinks.module.scss | 88 ++++++++++++ .../components/ModernPromotedLinks.tsx | 51 +++++++ src/webparts/modernPromotedLinks/loc/de-de.js | 17 +++ src/webparts/modernPromotedLinks/loc/en-us.js | 17 +++ .../modernPromotedLinks/loc/myStrings.d.ts | 20 +++ tests/PromotedLinkCore.test.ts | 32 +++++ tests/tsconfig.json | 16 +++ tests/validate-project.js | 42 ++++++ tsconfig.json | 16 +++ tslint.json | 30 ++++ 28 files changed, 759 insertions(+) create mode 100644 .gitignore create mode 100644 .yo-rc.json create mode 100644 README.md create mode 100644 config/config.json create mode 100644 config/copy-assets.json create mode 100644 config/package-solution.json create mode 100644 config/serve.json create mode 100644 config/write-manifests.json create mode 100644 gulpfile.js create mode 100644 package.json create mode 100644 src/declarations.d.ts create mode 100644 src/index.ts create mode 100644 src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.manifest.json create mode 100644 src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.ts create mode 100644 src/webparts/modernPromotedLinks/PromotedLinkCore.ts create mode 100644 src/webparts/modernPromotedLinks/PromotedLinkTypes.ts create mode 100644 src/webparts/modernPromotedLinks/PromotedLinksDataService.ts create mode 100644 src/webparts/modernPromotedLinks/components/IModernPromotedLinksProps.ts create mode 100644 src/webparts/modernPromotedLinks/components/ModernPromotedLinks.module.scss create mode 100644 src/webparts/modernPromotedLinks/components/ModernPromotedLinks.tsx create mode 100644 src/webparts/modernPromotedLinks/loc/de-de.js create mode 100644 src/webparts/modernPromotedLinks/loc/en-us.js create mode 100644 src/webparts/modernPromotedLinks/loc/myStrings.d.ts create mode 100644 tests/PromotedLinkCore.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/validate-project.js create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a69a71 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..e9d173b --- /dev/null +++ b/.yo-rc.json @@ -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" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..79574de --- /dev/null +++ b/README.md @@ -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. diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..33433bd --- /dev/null +++ b/config/config.json @@ -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" + } +} diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..3771fd0 --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", + "deployCdnPath": "temp/deploy" +} diff --git a/config/package-solution.json b/config/package-solution.json new file mode 100644 index 0000000..cac3ccf --- /dev/null +++ b/config/package-solution.json @@ -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" + } +} diff --git a/config/serve.json b/config/serve.json new file mode 100644 index 0000000..090cfe9 --- /dev/null +++ b/config/serve.json @@ -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/" + } +} diff --git a/config/write-manifests.json b/config/write-manifests.json new file mode 100644 index 0000000..11dca67 --- /dev/null +++ b/config/write-manifests.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", + "cdnBasePath": "" +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..4ce2d54 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,3 @@ +'use strict'; +const build = require('@microsoft/sp-build-web'); +build.initialize(require('gulp')); diff --git a/package.json b/package.json new file mode 100644 index 0000000..da65549 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..dc8c2f7 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const styles: { [className: string]: string }; + export = styles; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8950613 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default as ModernPromotedLinksWebPart } from './webparts/modernPromotedLinks/ModernPromotedLinksWebPart'; diff --git a/src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.manifest.json b/src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.manifest.json new file mode 100644 index 0000000..67fd129 --- /dev/null +++ b/src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.manifest.json @@ -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 + } + }] +} diff --git a/src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.ts b/src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.ts new file mode 100644 index 0000000..182d5a5 --- /dev/null +++ b/src/webparts/modernPromotedLinks/ModernPromotedLinksWebPart.ts @@ -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 { + private service: PromotedLinksDataService; + private lists: IPromotedLinksListOption[] = []; + private loadingLists: boolean = false; + private requestVersion: number = 0; + private disposed: boolean = false; + + protected onInit(): Promise { + this.service = new PromotedLinksDataService(this.context); + return super.onInit().then((): Promise => 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 = 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 { + 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); + } +} diff --git a/src/webparts/modernPromotedLinks/PromotedLinkCore.ts b/src/webparts/modernPromotedLinks/PromotedLinkCore.ts new file mode 100644 index 0000000..f56e206 --- /dev/null +++ b/src/webparts/modernPromotedLinks/PromotedLinkCore.ts @@ -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(/^\.\//, '') : ''; +} diff --git a/src/webparts/modernPromotedLinks/PromotedLinkTypes.ts b/src/webparts/modernPromotedLinks/PromotedLinkTypes.ts new file mode 100644 index 0000000..1b4e7eb --- /dev/null +++ b/src/webparts/modernPromotedLinks/PromotedLinkTypes.ts @@ -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; +} diff --git a/src/webparts/modernPromotedLinks/PromotedLinksDataService.ts b/src/webparts/modernPromotedLinks/PromotedLinksDataService.ts new file mode 100644 index 0000000..1a37c27 --- /dev/null +++ b/src/webparts/modernPromotedLinks/PromotedLinksDataService.ts @@ -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 { + 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 { + 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 { // tslint:disable-line:no-any + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any + if (response.ok) { return response.json(); } + return response.text().then((text: string): Promise => // 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(); } +} diff --git a/src/webparts/modernPromotedLinks/components/IModernPromotedLinksProps.ts b/src/webparts/modernPromotedLinks/components/IModernPromotedLinksProps.ts new file mode 100644 index 0000000..a056786 --- /dev/null +++ b/src/webparts/modernPromotedLinks/components/IModernPromotedLinksProps.ts @@ -0,0 +1,9 @@ +import { IPromotedLinkItem } from '../PromotedLinkTypes'; + +export interface IModernPromotedLinksProps { + items: IPromotedLinkItem[]; + columns: number; + tileHeight: number; + showDescriptions: boolean; + dialogWindowLabel: string; +} diff --git a/src/webparts/modernPromotedLinks/components/ModernPromotedLinks.module.scss b/src/webparts/modernPromotedLinks/components/ModernPromotedLinks.module.scss new file mode 100644 index 0000000..81d7bd3 --- /dev/null +++ b/src/webparts/modernPromotedLinks/components/ModernPromotedLinks.module.scss @@ -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); } +} diff --git a/src/webparts/modernPromotedLinks/components/ModernPromotedLinks.tsx b/src/webparts/modernPromotedLinks/components/ModernPromotedLinks.tsx new file mode 100644 index 0000000..004ca81 --- /dev/null +++ b/src/webparts/modernPromotedLinks/components/ModernPromotedLinks.tsx @@ -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 { + public render(): React.ReactElement { + const gridClass: string = styles.grid + ' ' + styles['columns' + this.props.columns]; + return ( +
+
    + {this.props.items.map((item: IPromotedLinkItem): JSX.Element => this.renderTile(item))} +
+
+ ); + } + + 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 ( +
  • + ): void => this.openDialog(event, item) : undefined}> + {!item.imageUrl && } + + {item.title || item.linkUrl} + {this.props.showDescriptions && item.description && {item.description}} + + +
  • + ); + } + + private openDialog(event: React.MouseEvent, 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, ''); + } +} diff --git a/src/webparts/modernPromotedLinks/loc/de-de.js b/src/webparts/modernPromotedLinks/loc/de-de.js new file mode 100644 index 0000000..dea68c0 --- /dev/null +++ b/src/webparts/modernPromotedLinks/loc/de-de.js @@ -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' + }; +}); diff --git a/src/webparts/modernPromotedLinks/loc/en-us.js b/src/webparts/modernPromotedLinks/loc/en-us.js new file mode 100644 index 0000000..bb8fd9c --- /dev/null +++ b/src/webparts/modernPromotedLinks/loc/en-us.js @@ -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' + }; +}); diff --git a/src/webparts/modernPromotedLinks/loc/myStrings.d.ts b/src/webparts/modernPromotedLinks/loc/myStrings.d.ts new file mode 100644 index 0000000..53d8d89 --- /dev/null +++ b/src/webparts/modernPromotedLinks/loc/myStrings.d.ts @@ -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; +} diff --git a/tests/PromotedLinkCore.test.ts b/tests/PromotedLinkCore.test.ts new file mode 100644 index 0000000..4e076db --- /dev/null +++ b/tests/PromotedLinkCore.test.ts @@ -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); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..a935f8e --- /dev/null +++ b/tests/tsconfig.json @@ -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" + ] +} diff --git a/tests/validate-project.js b/tests/validate-project.js new file mode 100644 index 0000000..2c8bb7b --- /dev/null +++ b/tests/validate-project.js @@ -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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a0877f --- /dev/null +++ b/tsconfig.json @@ -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"] + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..1fb24e6 --- /dev/null +++ b/tslint.json @@ -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 + } +}