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.
This commit is contained in:
+19
@@ -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
|
||||||
|
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||||
|
"deployCdnPath": "temp/deploy"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||||
|
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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'));
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.module.scss' {
|
||||||
|
const styles: { [className: string]: string };
|
||||||
|
export default styles;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Required TypeScript entry file.
|
||||||
|
|
||||||
@@ -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<IAccordionListOption[]> {
|
||||||
|
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<any> => 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<IAccordionFieldOption[]> {
|
||||||
|
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<any> => 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<IAccordionItem[]> {
|
||||||
|
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<any> => 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<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 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(); }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -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<IAccordionWebPartProps> {
|
||||||
|
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<void> {
|
||||||
|
this.service = new AccordionDataService(this.context);
|
||||||
|
return super.onInit().then((): Promise<void> => this.loadLists())
|
||||||
|
.then((): Promise<void> => 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<IAccordionProps> = 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]";
|
||||||
|
}
|
||||||
@@ -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<IAccordionProps, IAccordionState> {
|
||||||
|
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<any> { // tslint:disable-line:no-any
|
||||||
|
const groups: Array<{ name: string; items: IAccordionItem[] }> = this.groupItems();
|
||||||
|
return <div className={styles.accordion}>
|
||||||
|
{groups.map((group, groupIndex) =>
|
||||||
|
<section className={styles.category} key={'group-' + groupIndex}>
|
||||||
|
{this.props.showCategories && group.name
|
||||||
|
? <h2 className={styles.categoryTitle}>{group.name}</h2>
|
||||||
|
: undefined}
|
||||||
|
<div>{group.items.map((item: IAccordionItem) => this.renderItem(item))}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderItem(item: IAccordionItem): React.ReactElement<any> { // 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 <div className={styles.item} key={item.id}>
|
||||||
|
<h3 className={styles.heading}>
|
||||||
|
<button type='button' id={buttonId} className={styles.trigger}
|
||||||
|
aria-expanded={open} aria-controls={panelId}
|
||||||
|
aria-label={(open ? this.props.collapseLabel : this.props.expandLabel) + ' ' + item.title}
|
||||||
|
onClick={() => this.toggle(item.id)}>
|
||||||
|
<span className={styles.title}>{item.title}</span>
|
||||||
|
<span className={open ? styles.chevronOpen : styles.chevron} aria-hidden='true' />
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div id={panelId} className={styles.panel} role='region' aria-labelledby={buttonId} hidden={!open}>
|
||||||
|
<div className={styles.content} dangerouslySetInnerHTML={{ __html: sanitizeAccordionHtml(item.content) }} />
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
+31
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+31
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user