From bf9781d043aed4067badb36c42a8d22a78db3ff6 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sun, 23 Aug 2026 22:34:12 +0200 Subject: [PATCH] Add Accordion web part implementation with dynamic SharePoint list integration - Created Accordion web part with SPFx 1.4.1 framework. - Implemented dynamic loading of SharePoint lists and fields. - Added configuration options for title, content, and category fields. - Included support for multiple open sections and initial state settings. - Developed HTML sanitizer for rich text content. - Implemented accessible accordion UI with category grouping. - Added localization support for English and German languages. - Created necessary configuration files for deployment and asset management. - Added validation script to ensure project compliance with standards. --- .gitignore | 19 ++ .yo-rc.json | 12 + README.md | 62 +++++ ToDo.md | 14 + config/config.json | 17 ++ config/copy-assets.json | 5 + config/package-solution.json | 15 ++ config/serve.json | 11 + config/write-manifests.json | 5 + gulpfile.js | 6 + package.json | 35 +++ src/declarations.d.ts | 4 + src/index.ts | 2 + .../accordion/AccordionDataService.ts | 98 +++++++ .../accordion/AccordionHtmlSanitizer.ts | 65 +++++ src/webparts/accordion/AccordionTypes.ts | 20 ++ .../accordion/AccordionWebPart.manifest.json | 30 +++ src/webparts/accordion/AccordionWebPart.ts | 242 ++++++++++++++++++ .../components/Accordion.module.scss | 70 +++++ .../accordion/components/Accordion.tsx | 87 +++++++ .../accordion/components/IAccordionProps.ts | 12 + src/webparts/accordion/loc/de-de.js | 29 +++ src/webparts/accordion/loc/en-us.js | 29 +++ src/webparts/accordion/loc/myStrings.d.ts | 31 +++ tests/validate-project.js | 41 +++ tsconfig.json | 17 ++ tslint.json | 31 +++ 27 files changed, 1009 insertions(+) create mode 100644 .gitignore create mode 100644 .yo-rc.json create mode 100644 README.md create mode 100644 ToDo.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/accordion/AccordionDataService.ts create mode 100644 src/webparts/accordion/AccordionHtmlSanitizer.ts create mode 100644 src/webparts/accordion/AccordionTypes.ts create mode 100644 src/webparts/accordion/AccordionWebPart.manifest.json create mode 100644 src/webparts/accordion/AccordionWebPart.ts create mode 100644 src/webparts/accordion/components/Accordion.module.scss create mode 100644 src/webparts/accordion/components/Accordion.tsx create mode 100644 src/webparts/accordion/components/IAccordionProps.ts create mode 100644 src/webparts/accordion/loc/de-de.js create mode 100644 src/webparts/accordion/loc/en-us.js create mode 100644 src/webparts/accordion/loc/myStrings.d.ts 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..c678b59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +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..820cff0 --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,12 @@ +{ + "@microsoft/generator-sharepoint": { + "version": "1.4.1", + "libraryName": "accordion", + "libraryId": "27a6fd90-42a1-4e39-8862-08ed0e7803bc", + "environment": "onprem", + "packageManager": "npm", + "solutionName": "accordion", + "componentType": "webpart" + } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..500df95 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Accordion + +Accordion 1.0.0 ist ein listenbasiertes SPFx-WebPart für SharePoint Server Subscription Edition. Es orientiert +sich funktional am PnP-Beispiel `react-accordion-dynamic-section`, verwendet aber die mit SharePoint SE +kompatible SPFx-1.4.1-/React-15-Toolchain. + +## Funktionen + +- Auswahl einer nicht ausgeblendeten Liste aus dem aktuellen Web +- dynamische Auswahl von Überschrift-, Inhalt-, Kategorie- und Sortierspalte +- optionales Gruppieren nach Kategorien +- auf- oder absteigende Sortierung +- initial vollständig geschlossen, erster Eintrag geöffnet oder alle Einträge geöffnet +- wahlweise genau ein oder mehrere gleichzeitig geöffnete Bereiche +- semantische Buttons mit `aria-expanded`, `aria-controls` und zugeordneten Inhaltsregionen +- SharePoint-Themefarben und Standardtypografie +- bereinigte Rich-Text-Ausgabe ohne Skripte, Eventhandler, Bilder oder unsichere Links +- zentrale Bereitstellung über den App Catalog + +## Erwartete Liste + +Es wird keine fachliche Liste provisioniert. Eine einfache FAQ-Liste kann beispielsweise folgende Spalten besitzen: + +| Spalte | Typ | Verwendung | +|---|---|---| +| `Title` | Eine Textzeile | Überschrift | +| `Content` | Mehrere Textzeilen mit Rich Text | Inhalt | +| `Category` | Auswahl oder Text | optionale Gruppierung | +| `SortOrder` | Zahl | optionale Sortierung | + +Nach dem Hinzufügen des WebParts werden Liste und Spalten im Property Pane ausgewählt. Änderungen an den +Listeneinträgen erscheinen beim nächsten Laden beziehungsweise Rendern der Seite automatisch. + +## Version und Build + +| Eigenschaft | Wert | +|---|---| +| Version | 1.0.0 | +| SharePoint Framework | 1.4.1 | +| React | 15.6.2 | +| Node.js | 8.17.0 | +| npm | 6.13.4 | + +```powershell +npm install +npm run package +``` + +Das Paket wird unter `sharepoint/solution/accordion.sppkg` erzeugt. Es kann im App Catalog zentral bereitgestellt +werden. Danach steht das WebPart unter dem Namen **Accordion** beziehungsweise **Akkordeon** im WebPart-Picker zur Verfügung. + +## Sicherheit + +Der Inhalt einer Rich-Text-Spalte wird nicht ungeprüft ausgegeben. Erlaubt sind grundlegende Text-, Listen-, +Tabellen- und Linkelemente. Skripte, eingebettete Inhalte, Eventhandler, Inline-Styles und unsichere URL-Schemata +werden entfernt. + +## Referenz + +Die fachliche Vorlage ist das PnP Community Sample: +https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-accordion-dynamic-section + diff --git a/ToDo.md b/ToDo.md new file mode 100644 index 0000000..ab045a4 --- /dev/null +++ b/ToDo.md @@ -0,0 +1,14 @@ +# Accordion – Umsetzungsstand + +Zielversion: 1.0.0 +Status: Implementierung abgeschlossen; Ship-Build auf der Node-8-Buildumgebung ausstehend + +- [x] SPFx-1.4.1-WebPart-Grundgerüst +- [x] Listen und unterstützte Spalten dynamisch laden +- [x] Überschrift, Inhalt, Kategorie und Sortierung konfigurierbar machen +- [x] Barrierearmes Accordion mit Einzelauswahl und Mehrfachauswahl +- [x] Initialzustand `collapsed`, `first` und `all` +- [x] Rich Text sicher bereinigen +- [x] SharePoint-Themefarben und Standardtypografie verwenden +- [x] Zentrale App-Catalog-Bereitstellung konfigurieren +- [ ] Ship-Paket auf Node 8.17.0 erzeugen und in SharePoint SE testen diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..cb2929f --- /dev/null +++ b/config/config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json", + "version": "2.0", + "bundles": { + "accordion-web-part": { + "components": [{ + "entrypoint": "./lib/webparts/accordion/AccordionWebPart.js", + "manifest": "./src/webparts/accordion/AccordionWebPart.manifest.json" + }] + } + }, + "externals": {}, + "localizedResources": { + "AccordionWebPartStrings": "lib/webparts/accordion/loc/{locale}.js" + } +} + diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..0c899a2 --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1,5 @@ +{ + "$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..0b145ba --- /dev/null +++ b/config/package-solution.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json", + "solution": { + "name": "accordion-client-side-solution", + "title": "Accordion", + "id": "27a6fd90-42a1-4e39-8862-08ed0e7803bc", + "version": "1.0.0.0", + "includeClientSideAssets": true, + "skipFeatureDeployment": true + }, + "paths": { + "zippedPackage": "solution/accordion.sppkg" + } +} + diff --git a/config/serve.json b/config/serve.json new file mode 100644 index 0000000..dacf07b --- /dev/null +++ b/config/serve.json @@ -0,0 +1,11 @@ +{ + "$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..bc669ad --- /dev/null +++ b/config/write-manifests.json @@ -0,0 +1,5 @@ +{ + "$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..d1cf73e --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,6 @@ +'use strict'; + +const build = require('@microsoft/sp-build-web'); +build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`); +build.initialize(require('gulp')); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdf146a --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "accordion", + "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 . && 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..f8b65df --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const styles: { [className: string]: string }; + export default styles; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..147e3fe --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +// Required TypeScript entry file. + diff --git a/src/webparts/accordion/AccordionDataService.ts b/src/webparts/accordion/AccordionDataService.ts new file mode 100644 index 0000000..697c9b1 --- /dev/null +++ b/src/webparts/accordion/AccordionDataService.ts @@ -0,0 +1,98 @@ +import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { IAccordionFieldOption, IAccordionItem, IAccordionListOption } from './AccordionTypes'; + +export class AccordionDataService { + public constructor(private context: WebPartContext) { } + + public getLists(): Promise { + const url: string = this.webUrl + '/_api/web/lists?$select=Id,Title&$filter=Hidden eq false&$orderby=Title'; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response)) // tslint:disable-line:no-any + .then((payload: any): IAccordionListOption[] => this.results(payload).map((list: any): IAccordionListOption => ({ // tslint:disable-line:no-any + id: this.cleanGuid(list.Id), + title: String(list.Title || list.Id) + }))); + } + + public getFields(listId: string): Promise { + if (!this.isGuid(listId)) { return Promise.resolve([]); } + const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + + "')/fields?$select=InternalName,Title,TypeAsString,Hidden,ReadOnlyField&$filter=Hidden eq false&$orderby=Title"; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response)) // tslint:disable-line:no-any + .then((payload: any): IAccordionFieldOption[] => this.results(payload) // tslint:disable-line:no-any + .filter((field: any): boolean => !field.ReadOnlyField && this.isSupportedField(field.TypeAsString)) // tslint:disable-line:no-any + .map((field: any): IAccordionFieldOption => ({ // tslint:disable-line:no-any + internalName: String(field.InternalName || ''), + title: String(field.Title || field.InternalName || ''), + type: String(field.TypeAsString || '') + }))); + } + + public getItems( + listId: string, + titleField: string, + contentField: string, + categoryField: string, + sortField: string, + sortAscending: boolean + ): Promise { + if (!this.isGuid(listId)) { return Promise.reject(new Error('Invalid list ID.')); } + const fields: string[] = this.uniqueFields(['Id', titleField, contentField, categoryField]); + for (let i: number = 0; i < fields.length; i++) { + if (!this.isFieldName(fields[i])) { return Promise.reject(new Error('Invalid field name.')); } + } + const orderField: string = this.isFieldName(sortField) ? sortField : titleField; + const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + "')/items" + + '?$select=' + fields.join(',') + '&$orderby=' + orderField + (sortAscending ? ' asc' : ' desc') + '&$top=5000'; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response)) // tslint:disable-line:no-any + .then((payload: any): IAccordionItem[] => this.results(payload).map((item: any): IAccordionItem => ({ // tslint:disable-line:no-any + id: Number(item.Id), + title: this.toText(item[titleField]), + content: this.toText(item[contentField]), + category: categoryField ? this.toText(item[categoryField]) : '' + }))); + } + + 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 ensureJson(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 uniqueFields(values: string[]): string[] { + const result: string[] = []; + for (let i: number = 0; i < values.length; i++) { + const value: string = String(values[i] || '').trim(); + if (value && result.indexOf(value) < 0) { result.push(value); } + } + return result; + } + + private toText(value: any): string { // tslint:disable-line:no-any + if (value === undefined || value === null) { return ''; } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } + if (value.Label) { return String(value.Label); } + if (value.Description || value.Url) { return String(value.Description || value.Url); } + if (Array.isArray(value.results)) { return value.results.map((entry: any): string => this.toText(entry)).join(', '); } // tslint:disable-line:no-any + return ''; + } + + private isSupportedField(type: string): boolean { + return ['Text', 'Note', 'Choice', 'MultiChoice', 'Number', 'Integer', 'Boolean', 'DateTime', 'Computed'].indexOf(String(type)) >= 0; + } + private isFieldName(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value || '')); } + 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/accordion/AccordionHtmlSanitizer.ts b/src/webparts/accordion/AccordionHtmlSanitizer.ts new file mode 100644 index 0000000..415d230 --- /dev/null +++ b/src/webparts/accordion/AccordionHtmlSanitizer.ts @@ -0,0 +1,65 @@ +const AllowedTags: string[] = [ + 'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul' +]; +const DropContentTags: string[] = [ + 'button', 'embed', 'form', 'iframe', 'input', 'math', 'object', 'option', 'script', 'select', 'style', 'svg', 'textarea' +]; +const AllowedAttributes: { [tag: string]: string[] } = { + a: ['href', 'title', 'target'], + td: ['colspan', 'rowspan'], + th: ['colspan', 'rowspan'] +}; + +export function sanitizeAccordionHtml(value: string): string { + const host: HTMLDivElement = document.createElement('div'); + host.innerHTML = String(value || ''); + sanitizeChildren(host); + return host.innerHTML; +} + +function sanitizeChildren(parent: HTMLElement): void { + let node: Node = parent.firstChild; + while (node) { + const next: Node = node.nextSibling; + if (node.nodeType === 1) { + const element: HTMLElement = node as HTMLElement; + const tag: string = element.tagName.toLowerCase(); + if (AllowedTags.indexOf(tag) < 0) { + if (DropContentTags.indexOf(tag) < 0) { + while (element.firstChild) { parent.insertBefore(element.firstChild, element); } + } + parent.removeChild(element); + } else { + sanitizeAttributes(element, tag); + sanitizeChildren(element); + } + } else if (node.nodeType !== 3) { + parent.removeChild(node); + } + node = next; + } +} + +function sanitizeAttributes(element: HTMLElement, tag: string): void { + const allowed: string[] = AllowedAttributes[tag] || []; + for (let i: number = element.attributes.length - 1; i >= 0; i--) { + const name: string = element.attributes[i].name.toLowerCase(); + if (allowed.indexOf(name) < 0) { element.removeAttribute(element.attributes[i].name); } + } + if (tag === 'a') { + const link: HTMLAnchorElement = element as HTMLAnchorElement; + const href: string = link.getAttribute('href') || ''; + if (!isSafeLink(href)) { link.removeAttribute('href'); } + if (link.getAttribute('target') === '_blank') { link.setAttribute('rel', 'noopener noreferrer'); } + else { link.removeAttribute('target'); } + } +} + +function isSafeLink(value: string): boolean { + const href: string = String(value || '').trim(); + if (!href || href.charAt(0) === '/' || href.charAt(0) === '#' || href.indexOf('./') === 0 || href.indexOf('../') === 0) { + return true; + } + return /^(https?:|mailto:)/i.test(href); +} diff --git a/src/webparts/accordion/AccordionTypes.ts b/src/webparts/accordion/AccordionTypes.ts new file mode 100644 index 0000000..944a83f --- /dev/null +++ b/src/webparts/accordion/AccordionTypes.ts @@ -0,0 +1,20 @@ +export type AccordionInitialState = 'collapsed' | 'first' | 'all'; + +export interface IAccordionListOption { + id: string; + title: string; +} + +export interface IAccordionFieldOption { + internalName: string; + title: string; + type: string; +} + +export interface IAccordionItem { + id: number; + title: string; + content: string; + category: string; +} + diff --git a/src/webparts/accordion/AccordionWebPart.manifest.json b/src/webparts/accordion/AccordionWebPart.manifest.json new file mode 100644 index 0000000..3dd3fad --- /dev/null +++ b/src/webparts/accordion/AccordionWebPart.manifest.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json", + "id": "18e20eee-2838-4032-8c9d-697e52099059", + "alias": "AccordionWebPart", + "componentType": "WebPart", + "version": "*", + "manifestVersion": 2, + "requiresCustomScript": false, + "preconfiguredEntries": [{ + "groupId": "5c03119e-3074-46fd-976b-c60198311f70", + "group": { "default": "Other", "de-de": "Weitere" }, + "title": { "default": "Accordion", "de-de": "Akkordeon" }, + "description": { + "default": "Displays SharePoint list items as an accessible accordion.", + "de-de": "Zeigt Einträge einer SharePoint-Liste als barrierearmes Akkordeon an." + }, + "officeFabricIconFontName": "BulletedList2", + "properties": { + "listId": "", + "titleField": "Title", + "contentField": "", + "categoryField": "", + "sortField": "Title", + "sortAscending": true, + "allowMultiple": false, + "initialState": "collapsed", + "showCategories": true + } + }] +} diff --git a/src/webparts/accordion/AccordionWebPart.ts b/src/webparts/accordion/AccordionWebPart.ts new file mode 100644 index 0000000..309be56 --- /dev/null +++ b/src/webparts/accordion/AccordionWebPart.ts @@ -0,0 +1,242 @@ +import * as React from 'react'; +import * as ReactDom from 'react-dom'; +import { Version } from '@microsoft/sp-core-library'; +import { + BaseClientSideWebPart, + IPropertyPaneConfiguration, + IPropertyPaneDropdownOption, + PropertyPaneDropdown, + PropertyPaneToggle +} from '@microsoft/sp-webpart-base'; +import * as strings from 'AccordionWebPartStrings'; +import Accordion from './components/Accordion'; +import { IAccordionProps } from './components/IAccordionProps'; +import { AccordionDataService } from './AccordionDataService'; +import { + AccordionInitialState, + IAccordionFieldOption, + IAccordionItem, + IAccordionListOption +} from './AccordionTypes'; +import styles from './components/Accordion.module.scss'; + +export interface IAccordionWebPartProps { + listId: string; + titleField: string; + contentField: string; + categoryField: string; + sortField: string; + sortAscending: boolean; + allowMultiple: boolean; + initialState: AccordionInitialState; + showCategories: boolean; +} + +export default class AccordionWebPart extends BaseClientSideWebPart { + private service: AccordionDataService; + private lists: IAccordionListOption[] = []; + private fields: IAccordionFieldOption[] = []; + private loadingLists: boolean = false; + private loadingFields: boolean = false; + private requestVersion: number = 0; + private disposed: boolean = false; + + protected onInit(): Promise { + this.service = new AccordionDataService(this.context); + return super.onInit().then((): Promise => this.loadLists()) + .then((): Promise => this.properties.listId ? this.loadFields(this.properties.listId) : Promise.resolve()); + } + + public render(): void { + const version: number = ++this.requestVersion; + if (!this.isConfigured()) { + this.renderStatus(strings.ConfigureMessage, false); + return; + } + this.renderStatus(strings.LoadingMessage, false); + this.service.getItems( + this.properties.listId, + this.properties.titleField, + this.properties.contentField, + this.properties.categoryField || '', + this.properties.sortField || this.properties.titleField, + this.properties.sortAscending !== false + ).then((items: IAccordionItem[]): void => { + if (this.disposed || version !== this.requestVersion) { return; } + if (!items.length) { this.renderStatus(strings.EmptyMessage, false); return; } + const element: React.ReactElement = React.createElement(Accordion, { + items: items, + allowMultiple: this.properties.allowMultiple === true, + initialState: this.normalizeInitialState(this.properties.initialState), + showCategories: this.properties.showCategories !== false && !!this.properties.categoryField, + expandLabel: strings.ExpandLabel, + collapseLabel: strings.CollapseLabel, + defaultCategory: strings.DefaultCategory + }); + ReactDom.render(element, this.domElement); + }).catch((error: Error): void => { + if (this.disposed || version !== this.requestVersion) { return; } + if (window.console) { console.error('[Accordion] ' + 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(); } + if (this.properties.listId && !this.fields.length && !this.loadingFields) { + this.loadFields(this.properties.listId); + } + } + + protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void { // tslint:disable-line:no-any + super.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue); + if (propertyPath === 'listId' && newValue !== oldValue) { + this.properties.titleField = ''; + this.properties.contentField = ''; + this.properties.categoryField = ''; + this.properties.sortField = ''; + this.fields = []; + this.context.propertyPane.refresh(); + this.loadFields(String(newValue || '')).then((): void => this.render()); + } + } + + protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { + const fieldOptions: IPropertyPaneDropdownOption[] = this.fields.map((field: IAccordionFieldOption): IPropertyPaneDropdownOption => ({ + key: field.internalName, text: field.title + ' (' + field.internalName + ')' + })); + const categoryOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.NoCategoryOption }]; + const listOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectOption }]; + for (let i: number = 0; i < fieldOptions.length; i++) { categoryOptions.push(fieldOptions[i]); } + 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 + }), + PropertyPaneDropdown('titleField', { + label: strings.TitleFieldLabel, options: fieldOptions, + disabled: !this.properties.listId || this.loadingFields + }), + PropertyPaneDropdown('contentField', { + label: strings.ContentFieldLabel, options: fieldOptions, + disabled: !this.properties.listId || this.loadingFields + }), + PropertyPaneDropdown('categoryField', { + label: strings.CategoryFieldLabel, options: categoryOptions, + disabled: !this.properties.listId || this.loadingFields + }), + PropertyPaneDropdown('sortField', { + label: strings.SortFieldLabel, options: fieldOptions, + disabled: !this.properties.listId || this.loadingFields + }), + PropertyPaneToggle('sortAscending', { label: strings.SortAscendingLabel }) + ] + }, { + groupName: strings.BehaviorGroupName, + groupFields: [ + PropertyPaneDropdown('initialState', { + label: strings.InitialStateFieldLabel, + options: [ + { key: 'collapsed', text: strings.InitialCollapsed }, + { key: 'first', text: strings.InitialFirst }, + { key: 'all', text: strings.InitialAll } + ] + }), + PropertyPaneToggle('allowMultiple', { label: strings.AllowMultipleLabel }), + PropertyPaneToggle('showCategories', { + label: strings.ShowCategoriesLabel, + disabled: !this.properties.categoryField + }) + ] + }] + }] + }; + } + + private loadLists(): Promise { + this.loadingLists = true; + this.refreshPropertyPane(); + return this.service.getLists().then((lists: IAccordionListOption[]): void => { + this.lists = lists; + }).catch((error: Error): void => { + this.lists = []; + if (window.console) { console.error('[Accordion] Lists could not be loaded.', error); } + }).then((): void => { + this.loadingLists = false; + this.refreshPropertyPane(); + }); + } + + private loadFields(listId: string): Promise { + if (!listId) { this.fields = []; return Promise.resolve(); } + this.loadingFields = true; + this.refreshPropertyPane(); + return this.service.getFields(listId).then((fields: IAccordionFieldOption[]): void => { + this.fields = fields; + this.applyFieldDefaults(); + }).catch((error: Error): void => { + this.fields = []; + if (window.console) { console.error('[Accordion] Fields could not be loaded.', error); } + }).then((): void => { + this.loadingFields = false; + this.refreshPropertyPane(); + }); + } + + private applyFieldDefaults(): void { + const names: string[] = this.fields.map((field: IAccordionFieldOption): string => field.internalName); + if (names.indexOf(this.properties.titleField) < 0) { + this.properties.titleField = names.indexOf('Title') >= 0 ? 'Title' : (names[0] || ''); + } + if (names.indexOf(this.properties.contentField) < 0) { + const note: IAccordionFieldOption = this.fields.filter((field: IAccordionFieldOption): boolean => + field.type === 'Note' && field.internalName !== this.properties.titleField)[0]; + this.properties.contentField = note ? note.internalName : this.firstDifferentField(this.properties.titleField); + } + if (names.indexOf(this.properties.categoryField) < 0) { this.properties.categoryField = ''; } + if (names.indexOf(this.properties.sortField) < 0) { this.properties.sortField = this.properties.titleField; } + } + + private firstDifferentField(excluded: string): string { + for (let i: number = 0; i < this.fields.length; i++) { + if (this.fields[i].internalName !== excluded) { return this.fields[i].internalName; } + } + return ''; + } + + private isConfigured(): boolean { + return !!(this.service && this.properties.listId && this.properties.titleField && this.properties.contentField); + } + private normalizeInitialState(value: string): AccordionInitialState { + return value === 'first' || value === 'all' ? value : 'collapsed'; + } + 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/accordion/components/Accordion.module.scss b/src/webparts/accordion/components/Accordion.module.scss new file mode 100644 index 0000000..bfa9e19 --- /dev/null +++ b/src/webparts/accordion/components/Accordion.module.scss @@ -0,0 +1,70 @@ +@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss'; + +.accordion { + font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + color: "[theme: neutralPrimary, default: #323130]"; +} + +.category { margin: 0 0 20px; } +.categoryTitle { + margin: 0 0 8px; + font-size: 20px; + font-weight: 600; + color: "[theme: neutralPrimary, default: #323130]"; +} +.item { border-bottom: 1px solid "[theme: neutralLight, default: #edebe9]"; } +.item:first-child { border-top: 1px solid "[theme: neutralLight, default: #edebe9]"; } +.heading { margin: 0; font-size: inherit; font-weight: inherit; } +.trigger { + align-items: center; + background: "[theme: white, default: #ffffff]"; + border: 0; + color: "[theme: neutralPrimary, default: #323130]"; + cursor: pointer; + display: flex; + font: inherit; + font-weight: 600; + justify-content: space-between; + min-height: 44px; + padding: 10px 12px; + text-align: left; + width: 100%; +} +.trigger:hover { background: "[theme: neutralLighter, default: #f3f2f1]"; } +.trigger:focus { + outline: 2px solid "[theme: themePrimary, default: #0078d4]"; + outline-offset: -2px; +} +.title { min-width: 0; padding-right: 12px; } +.chevron, +.chevronOpen { + border-bottom: 1px solid currentColor; + border-right: 1px solid currentColor; + flex: 0 0 8px; + height: 8px; + margin-right: 4px; + transform: rotate(45deg) translateY(-2px); + transition: transform 120ms ease; + width: 8px; +} +.chevronOpen { transform: rotate(225deg) translate(-2px, -2px); } +.panel { background: "[theme: white, default: #ffffff]"; } +.content { line-height: 1.5; padding: 4px 12px 16px; } +.content > :first-child { margin-top: 0; } +.content > :last-child { margin-bottom: 0; } +.content a { color: "[theme: themePrimary, default: #0078d4]"; } +.content table { border-collapse: collapse; max-width: 100%; } +.content td, +.content th { border: 1px solid "[theme: neutralLight, default: #edebe9]"; padding: 6px; } +.status, +.error { + border: 1px solid "[theme: neutralLight, default: #edebe9]"; + color: "[theme: neutralPrimary, default: #323130]"; + font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif; + padding: 16px; +} +.error { + border-color: "[theme: errorText, default: #a80000]"; + color: "[theme: errorText, default: #a80000]"; +} diff --git a/src/webparts/accordion/components/Accordion.tsx b/src/webparts/accordion/components/Accordion.tsx new file mode 100644 index 0000000..cc088de --- /dev/null +++ b/src/webparts/accordion/components/Accordion.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import styles from './Accordion.module.scss'; +import { sanitizeAccordionHtml } from '../AccordionHtmlSanitizer'; +import { IAccordionItem } from '../AccordionTypes'; +import { IAccordionProps } from './IAccordionProps'; + +export interface IAccordionState { openIds: number[]; } + +export default class Accordion extends React.Component { + private instanceId: string = 'accordion-' + Math.random().toString(36).substring(2, 10); + + public constructor(props: IAccordionProps) { + super(props); + this.state = { openIds: this.initialOpenIds(props) }; + } + + public componentWillReceiveProps(nextProps: IAccordionProps): void { + if (nextProps.items !== this.props.items || nextProps.initialState !== this.props.initialState) { + this.setState({ openIds: this.initialOpenIds(nextProps) }); + } + } + + public render(): React.ReactElement { // tslint:disable-line:no-any + const groups: Array<{ name: string; items: IAccordionItem[] }> = this.groupItems(); + return
+ {groups.map((group, groupIndex) => +
+ {this.props.showCategories && group.name + ?

{group.name}

+ : undefined} +
{group.items.map((item: IAccordionItem) => this.renderItem(item))}
+
+ )} +
; + } + + private renderItem(item: IAccordionItem): React.ReactElement { // tslint:disable-line:no-any + const open: boolean = this.state.openIds.indexOf(item.id) >= 0; + const buttonId: string = this.instanceId + '-button-' + item.id; + const panelId: string = this.instanceId + '-panel-' + item.id; + return
+

+ +

+ ; + } + + private toggle(id: number): void { + const open: boolean = this.state.openIds.indexOf(id) >= 0; + if (this.props.allowMultiple) { + this.setState({ openIds: open + ? this.state.openIds.filter((current: number): boolean => current !== id) + : this.state.openIds.concat([id]) }); + } else { + this.setState({ openIds: open ? [] : [id] }); + } + } + + private initialOpenIds(props: IAccordionProps): number[] { + if (props.initialState === 'all') { return props.items.map((item: IAccordionItem): number => item.id); } + return props.initialState === 'first' && props.items.length ? [props.items[0].id] : []; + } + + private groupItems(): Array<{ name: string; items: IAccordionItem[] }> { + if (!this.props.showCategories) { return [{ name: '', items: this.props.items }]; } + const result: Array<{ name: string; items: IAccordionItem[] }> = []; + const byName: { [name: string]: { name: string; items: IAccordionItem[] } } = {}; + for (let i: number = 0; i < this.props.items.length; i++) { + const name: string = this.props.items[i].category || this.props.defaultCategory; + if (!byName[name]) { + byName[name] = { name: name, items: [] }; + result.push(byName[name]); + } + byName[name].items.push(this.props.items[i]); + } + return result; + } +} diff --git a/src/webparts/accordion/components/IAccordionProps.ts b/src/webparts/accordion/components/IAccordionProps.ts new file mode 100644 index 0000000..2b8588d --- /dev/null +++ b/src/webparts/accordion/components/IAccordionProps.ts @@ -0,0 +1,12 @@ +import { IAccordionItem, AccordionInitialState } from '../AccordionTypes'; + +export interface IAccordionProps { + items: IAccordionItem[]; + allowMultiple: boolean; + initialState: AccordionInitialState; + showCategories: boolean; + expandLabel: string; + collapseLabel: string; + defaultCategory: string; +} + diff --git a/src/webparts/accordion/loc/de-de.js b/src/webparts/accordion/loc/de-de.js new file mode 100644 index 0000000..b586049 --- /dev/null +++ b/src/webparts/accordion/loc/de-de.js @@ -0,0 +1,29 @@ +define([], function() { + return { + "PropertyPaneDescription": "Wählen Sie die Liste und Spalten für das Akkordeon aus.", + "DataGroupName": "Datenquelle", + "BehaviorGroupName": "Verhalten", + "ListFieldLabel": "Liste", + "TitleFieldLabel": "Spalte für Überschrift", + "ContentFieldLabel": "Spalte für Inhalt", + "CategoryFieldLabel": "Spalte für Kategorie (optional)", + "SortFieldLabel": "Sortierspalte", + "SortAscendingLabel": "Aufsteigend sortieren", + "AllowMultipleLabel": "Mehrere geöffnete Bereiche erlauben", + "InitialStateFieldLabel": "Initial geöffnet", + "InitialCollapsed": "Keine", + "InitialFirst": "Erster Bereich", + "InitialAll": "Alle Bereiche", + "ShowCategoriesLabel": "Kategorieüberschriften anzeigen", + "SelectOption": "Auswählen...", + "NoCategoryOption": "Keine Kategorien", + "ConfigureMessage": "Konfigurieren Sie in den WebPart-Einstellungen eine Liste sowie Überschrift- und Inhaltsspalte.", + "LoadingMessage": "Akkordeon wird geladen...", + "EmptyMessage": "Es wurden keine Einträge gefunden.", + "LoadErrorMessage": "Die Akkordeon-Einträge konnten nicht geladen werden.", + "ExpandLabel": "Aufklappen", + "CollapseLabel": "Zuklappen", + "DefaultCategory": "Weitere" + }; +}); + diff --git a/src/webparts/accordion/loc/en-us.js b/src/webparts/accordion/loc/en-us.js new file mode 100644 index 0000000..93ecad5 --- /dev/null +++ b/src/webparts/accordion/loc/en-us.js @@ -0,0 +1,29 @@ +define([], function() { + return { + "PropertyPaneDescription": "Select the list and columns used by the accordion.", + "DataGroupName": "Data source", + "BehaviorGroupName": "Behavior", + "ListFieldLabel": "List", + "TitleFieldLabel": "Heading column", + "ContentFieldLabel": "Content column", + "CategoryFieldLabel": "Category column (optional)", + "SortFieldLabel": "Sort column", + "SortAscendingLabel": "Sort ascending", + "AllowMultipleLabel": "Allow multiple open sections", + "InitialStateFieldLabel": "Initially open", + "InitialCollapsed": "None", + "InitialFirst": "First section", + "InitialAll": "All sections", + "ShowCategoriesLabel": "Show category headings", + "SelectOption": "Select...", + "NoCategoryOption": "No categories", + "ConfigureMessage": "Configure a list, heading column and content column in the web part settings.", + "LoadingMessage": "Loading accordion...", + "EmptyMessage": "No entries were found.", + "LoadErrorMessage": "The accordion entries could not be loaded.", + "ExpandLabel": "Expand", + "CollapseLabel": "Collapse", + "DefaultCategory": "Other" + }; +}); + diff --git a/src/webparts/accordion/loc/myStrings.d.ts b/src/webparts/accordion/loc/myStrings.d.ts new file mode 100644 index 0000000..4f7cf73 --- /dev/null +++ b/src/webparts/accordion/loc/myStrings.d.ts @@ -0,0 +1,31 @@ +declare interface IAccordionWebPartStrings { + PropertyPaneDescription: string; + DataGroupName: string; + BehaviorGroupName: string; + ListFieldLabel: string; + TitleFieldLabel: string; + ContentFieldLabel: string; + CategoryFieldLabel: string; + SortFieldLabel: string; + SortAscendingLabel: string; + AllowMultipleLabel: string; + InitialStateFieldLabel: string; + InitialCollapsed: string; + InitialFirst: string; + InitialAll: string; + ShowCategoriesLabel: string; + SelectOption: string; + NoCategoryOption: string; + ConfigureMessage: string; + LoadingMessage: string; + EmptyMessage: string; + LoadErrorMessage: string; + ExpandLabel: string; + CollapseLabel: string; + DefaultCategory: string; +} + +declare module 'AccordionWebPartStrings' { + const strings: IAccordionWebPartStrings; + export = strings; +} diff --git a/tests/validate-project.js b/tests/validate-project.js new file mode 100644 index 0000000..9db5870 --- /dev/null +++ b/tests/validate-project.js @@ -0,0 +1,41 @@ +'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 solution = json('config/package-solution.json').solution; +var config = json('config/config.json'); +var manifest = json('src/webparts/accordion/AccordionWebPart.manifest.json'); +var webPart = read('src/webparts/accordion/AccordionWebPart.ts'); +var component = read('src/webparts/accordion/components/Accordion.tsx'); +var sanitizer = read('src/webparts/accordion/AccordionHtmlSanitizer.ts'); +var styles = read('src/webparts/accordion/components/Accordion.module.scss'); + +assert(packageJson.version === '1.0.0', 'Package version must be 1.0.0.'); +assert(solution.version === '1.0.0.0', 'Solution version must be 1.0.0.0.'); +assert(solution.includeClientSideAssets === true, 'Client-side assets must be included.'); +assert(solution.skipFeatureDeployment === true, 'Solution must support central deployment.'); +assert(manifest.componentType === 'WebPart', 'Component must be a web part.'); +assert(!!config.bundles['accordion-web-part'], 'Accordion bundle is missing.'); +assert(webPart.indexOf("PropertyPaneDropdown('listId'") >= 0, 'Dynamic list selection is missing.'); +assert(webPart.indexOf("PropertyPaneDropdown('contentField'") >= 0, 'Dynamic content field selection is missing.'); +assert(webPart.indexOf("PropertyPaneDropdown('categoryField'") >= 0, 'Category field selection is missing.'); +assert(component.indexOf("aria-expanded={open}") >= 0 && component.indexOf("role='region'") >= 0, + 'Accessible accordion semantics are missing.'); +assert(component.indexOf('sanitizeAccordionHtml(item.content)') >= 0, 'Rich text is not sanitized before rendering.'); +var allowedTags = sanitizer.substring(sanitizer.indexOf('const AllowedTags'), sanitizer.indexOf('const DropContentTags')); +assert(allowedTags.indexOf("'script'") < 0 && allowedTags.indexOf("'img'") < 0 && + sanitizer.indexOf("'script'") >= 0, + 'Unsafe rich-text elements must be removed rather than allow-listed.'); +['neutralPrimary', 'neutralLight', 'neutralLighter', 'themePrimary', 'white'].forEach(function (slot) { + assert(styles.indexOf('[theme: ' + slot + ', default:') >= 0, 'SharePoint theme slot is missing: ' + slot); +}); + +console.log('Accordion project validation passed:', passed); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..02f8cf2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "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..8407658 --- /dev/null +++ b/tslint.json @@ -0,0 +1,31 @@ +{ + "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 + } +} +