5 Commits
17 changed files with 329 additions and 65 deletions
+6 -3
View File
@@ -1,6 +1,6 @@
# Accordion # Accordion
Accordion 1.0.0 ist ein listenbasiertes SPFx-WebPart für SharePoint Server Subscription Edition. Es orientiert Accordion 1.2.1 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 sich funktional am PnP-Beispiel `react-accordion-dynamic-section`, verwendet aber die mit SharePoint SE
kompatible SPFx-1.4.1-/React-15-Toolchain. kompatible SPFx-1.4.1-/React-15-Toolchain.
@@ -8,8 +8,11 @@ kompatible SPFx-1.4.1-/React-15-Toolchain.
- Auswahl einer nicht ausgeblendeten Liste aus dem aktuellen Web - Auswahl einer nicht ausgeblendeten Liste aus dem aktuellen Web
- dynamische Auswahl von Überschrift-, Inhalt-, Kategorie- und Sortierspalte - dynamische Auswahl von Überschrift-, Inhalt-, Kategorie- und Sortierspalte
- konfigurierbarer WebPart-Titel
- optionales Gruppieren nach Kategorien - optionales Gruppieren nach Kategorien
- Filter auf eine konkrete, aktuell in der Liste verwendete Kategorie - Filter auf eine konkrete, aktuell in der Liste verwendete Kategorie
- ein- und mehrwertige Managed-Metadata-Spalten als Kategorien
- Auflösung interner WSS-IDs über die `TaxonomyHiddenList` der Site Collection
- auf- oder absteigende Sortierung - auf- oder absteigende Sortierung
- initial vollständig geschlossen, erster Eintrag geöffnet oder alle Einträge geöffnet - initial vollständig geschlossen, erster Eintrag geöffnet oder alle Einträge geöffnet
- wahlweise genau ein oder mehrere gleichzeitig geöffnete Bereiche - wahlweise genau ein oder mehrere gleichzeitig geöffnete Bereiche
@@ -26,7 +29,7 @@ Es wird keine fachliche Liste provisioniert. Eine einfache FAQ-Liste kann beispi
|---|---|---| |---|---|---|
| `Title` | Eine Textzeile | Überschrift | | `Title` | Eine Textzeile | Überschrift |
| `Content` | Mehrere Textzeilen mit Rich Text | Inhalt | | `Content` | Mehrere Textzeilen mit Rich Text | Inhalt |
| `Category` | Auswahl oder Text | optionale Gruppierung | | `Category` | Auswahl, Text oder Managed Metadata | optionale Gruppierung |
| `SortOrder` | Zahl | optionale Sortierung | | `SortOrder` | Zahl | optionale Sortierung |
Nach dem Hinzufügen des WebParts werden Liste und Spalten im Property Pane ausgewählt. Sobald eine Nach dem Hinzufügen des WebParts werden Liste und Spalten im Property Pane ausgewählt. Sobald eine
@@ -38,7 +41,7 @@ nächsten Laden beziehungsweise Rendern der Seite automatisch.
| Eigenschaft | Wert | | Eigenschaft | Wert |
|---|---| |---|---|
| Version | 1.0.0 | | Version | 1.2.1 |
| SharePoint Framework | 1.4.1 | | SharePoint Framework | 1.4.1 |
| React | 15.6.2 | | React | 15.6.2 |
| Node.js | 8.17.0 | | Node.js | 8.17.0 |
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "accordion-web-part", "name": "accordion-web-part",
"title": "accordion-web-part", "title": "accordion-web-part",
"id": "27a6fd90-42a1-4e39-8862-08ed0e7803bc", "id": "27a6fd90-42a1-4e39-8862-08ed0e7803bc",
"version": "1.0.0.0", "version": "1.2.1.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": true "skipFeatureDeployment": true
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "accordion-web-part", "name": "accordion-web-part",
"version": "1.0.0", "version": "1.2.1",
"private": true, "private": true,
"main": "lib/index.js", "main": "lib/index.js",
"engines": { "engines": {
@@ -9,7 +9,7 @@
"scripts": { "scripts": {
"build": "gulp bundle", "build": "gulp bundle",
"clean": "gulp clean", "clean": "gulp clean",
"test": "tsc --noEmit -p . && node tests/validate-project.js", "test": "tsc -p tests/tsconfig.json && node temp/tests/tests/AccordionCategoryValues.test.js && tsc --noEmit -p . && node tests/validate-project.js",
"package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship" "package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship"
}, },
"dependencies": { "dependencies": {
@@ -0,0 +1,63 @@
export function getAccordionCategoryValues(value: any, splitPlainSemicolon?: boolean): string[] { // tslint:disable-line:no-any
if (value === undefined || value === null) { return []; }
const source: any[] = value && Array.isArray(value.results) ? value.results : [value]; // tslint:disable-line:no-any
const values: string[] = [];
const keys: { [key: string]: boolean } = {};
for (let i: number = 0; i < source.length; i++) {
const current: string[] = categoryEntryValues(source[i], splitPlainSemicolon === true);
for (let valueIndex: number = 0; valueIndex < current.length; valueIndex++) {
const text: string = current[valueIndex].trim();
const key: string = '$' + text.toLocaleLowerCase();
if (text && !keys[key]) { keys[key] = true; values.push(text); }
}
}
return values;
}
function categoryEntryValues(entry: any, splitPlainSemicolon: boolean): string[] { // tslint:disable-line:no-any
if (entry === undefined || entry === null) { return []; }
if (entry.Label) { return [String(entry.Label)]; }
if (typeof entry !== 'string') {
if (typeof entry === 'number' || typeof entry === 'boolean') { return [String(entry)]; }
if (entry.Description || entry.Url) { return [String(entry.Description || entry.Url)]; }
return [];
}
const candidate: string = entry.trim();
if (!candidate) { return []; }
let parts: string[] = [candidate];
if (candidate.indexOf(';#') >= 0) {
parts = candidate.split(';#');
} else if (candidate.indexOf(';') >= 0) {
const semicolonParts: string[] = candidate.split(';');
const allTaxonomyValues: boolean = semicolonParts.every((part: string): boolean =>
/\|[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(part.trim()));
if (allTaxonomyValues || splitPlainSemicolon) { parts = semicolonParts; }
}
const values: string[] = [];
for (let i: number = 0; i < parts.length; i++) {
const part: string = parts[i].trim();
if (!part || /^-?\d+$/.test(part)) { continue; }
const taxonomyMatch: RegExpMatchArray = part.match(/^(.*)\|[0-9a-f]{8}-[0-9a-f-]{27}$/i);
values.push(taxonomyMatch ? taxonomyMatch[1].trim() : part);
}
return values.filter((text: string): boolean => !!text);
}
export function replaceAccordionCategoryIds(
values: string[],
labelsByWssId: { [wssId: string]: string }
): string[] {
const result: string[] = [];
const keys: { [key: string]: boolean } = {};
for (let i: number = 0; i < values.length; i++) {
const value: string = values[i];
const label: string = /^\d+$/.test(value) && labelsByWssId[value]
? labelsByWssId[value]
: value;
const key: string = '$' + label.toLocaleLowerCase();
if (label && !keys[key]) { keys[key] = true; result.push(label); }
}
return result;
}
+113 -26
View File
@@ -1,8 +1,12 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getAccordionCategoryValues, replaceAccordionCategoryIds } from './AccordionCategoryValues';
import { IAccordionFieldOption, IAccordionItem, IAccordionListOption } from './AccordionTypes'; import { IAccordionFieldOption, IAccordionItem, IAccordionListOption } from './AccordionTypes';
export class AccordionDataService { export class AccordionDataService {
private taxonomyLabelsByWssId: { [wssId: string]: string } = {};
private missingTaxonomyWssIds: { [wssId: string]: boolean } = {};
public constructor(private context: WebPartContext) { } public constructor(private context: WebPartContext) { }
public getLists(): Promise<IAccordionListOption[]> { public getLists(): Promise<IAccordionListOption[]> {
@@ -37,7 +41,8 @@ export class AccordionDataService {
categoryField: string, categoryField: string,
sortField: string, sortField: string,
sortAscending: boolean, sortAscending: boolean,
categoryValue: string categoryValue: string,
categoryFieldType: string
): Promise<IAccordionItem[]> { ): Promise<IAccordionItem[]> {
if (!this.isGuid(listId)) { return Promise.reject(new Error('Invalid list ID.')); } if (!this.isGuid(listId)) { return Promise.reject(new Error('Invalid list ID.')); }
const fields: string[] = this.uniqueFields(['Id', titleField, contentField, categoryField]); const fields: string[] = this.uniqueFields(['Id', titleField, contentField, categoryField]);
@@ -45,40 +50,59 @@ export class AccordionDataService {
if (!this.isFieldName(fields[i])) { return Promise.reject(new Error('Invalid field name.')); } if (!this.isFieldName(fields[i])) { return Promise.reject(new Error('Invalid field name.')); }
} }
const orderField: string = this.isFieldName(sortField) ? sortField : titleField; const orderField: string = this.isFieldName(sortField) ? sortField : titleField;
const taxonomyCategory: boolean = this.isTaxonomyType(categoryFieldType) && !!categoryField;
const formattedSelect: string = taxonomyCategory ? ',FieldValuesAsText/' + categoryField : '';
const formattedExpand: string = taxonomyCategory ? '&$expand=FieldValuesAsText' : '';
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + "')/items" + const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + "')/items" +
'?$select=' + fields.join(',') + '&$orderby=' + orderField + (sortAscending ? ' asc' : ' desc') + '&$top=5000'; '?$select=' + fields.join(',') + formattedSelect + formattedExpand +
'&$orderby=' + orderField + (sortAscending ? ' asc' : ' desc') + '&$top=5000';
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) 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((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response)) // tslint:disable-line:no-any
.then((payload: any): IAccordionItem[] => this.results(payload) // tslint:disable-line:no-any .then((payload: any): Promise<IAccordionItem[]> => { // tslint:disable-line:no-any
.filter((item: any): boolean => !categoryValue || // tslint:disable-line:no-any const items: any[] = this.results(payload); // tslint:disable-line:no-any
this.toValues(item[categoryField]).indexOf(categoryValue) >= 0) return this.resolveItemCategoryValues(items, categoryField, taxonomyCategory)
.map((item: any): IAccordionItem => ({ // tslint:disable-line:no-any .then((categoriesByItem: string[][]): IAccordionItem[] => {
id: Number(item.Id), const result: IAccordionItem[] = [];
title: this.toText(item[titleField]), for (let i: number = 0; i < items.length; i++) {
content: this.toText(item[contentField]), const categories: string[] = categoriesByItem[i];
category: categoryField ? this.toText(item[categoryField]) : '' if (categoryValue && categories.indexOf(categoryValue) < 0) { continue; }
}))); result.push({
id: Number(items[i].Id),
title: this.toText(items[i][titleField]),
content: this.toText(items[i][contentField]),
categories: categories
});
}
return result;
});
});
} }
public getCategoryValues(listId: string, categoryField: string): Promise<string[]> { public getCategoryValues(listId: string, categoryField: string, categoryFieldType: string): Promise<string[]> {
if (!this.isGuid(listId) || !this.isFieldName(categoryField)) { return Promise.resolve([]); } if (!this.isGuid(listId) || !this.isFieldName(categoryField)) { return Promise.resolve([]); }
const taxonomyCategory: boolean = this.isTaxonomyType(categoryFieldType);
const formattedSelect: string = taxonomyCategory ? ',FieldValuesAsText/' + categoryField : '';
const formattedExpand: string = taxonomyCategory ? '&$expand=FieldValuesAsText' : '';
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + "')/items" + const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) + "')/items" +
'?$select=' + categoryField + '&$top=5000'; '?$select=' + categoryField + formattedSelect + formattedExpand + '&$top=5000';
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) 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((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response)) // tslint:disable-line:no-any
.then((payload: any): string[] => { // tslint:disable-line:no-any .then((payload: any): Promise<string[]> => { // tslint:disable-line:no-any
const items: any[] = this.results(payload); // tslint:disable-line:no-any
return this.resolveItemCategoryValues(items, categoryField, taxonomyCategory)
.then((categoriesByItem: string[][]): string[] => {
const values: string[] = []; const values: string[] = [];
const keys: { [key: string]: boolean } = {}; const keys: { [key: string]: boolean } = {};
const items: any[] = this.results(payload); // tslint:disable-line:no-any for (let i: number = 0; i < categoriesByItem.length; i++) {
for (let i: number = 0; i < items.length; i++) { const current: string[] = categoriesByItem[i];
const current: string[] = this.toValues(items[i][categoryField]);
for (let valueIndex: number = 0; valueIndex < current.length; valueIndex++) { for (let valueIndex: number = 0; valueIndex < current.length; valueIndex++) {
const key: string = current[valueIndex].toLocaleLowerCase(); const key: string = '$' + current[valueIndex].toLocaleLowerCase();
if (!keys[key]) { keys[key] = true; values.push(current[valueIndex]); } if (!keys[key]) { keys[key] = true; values.push(current[valueIndex]); }
} }
} }
return values.sort((left: string, right: string): number => left.localeCompare(right)); return values.sort((left: string, right: string): number => left.localeCompare(right));
}); });
});
} }
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); } private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
@@ -104,6 +128,72 @@ export class AccordionDataService {
return result; return result;
} }
private itemCategoryValues(item: any, categoryField: string, taxonomyCategory: boolean): string[] { // tslint:disable-line:no-any
if (taxonomyCategory && item && item.FieldValuesAsText) {
const formatted: string[] = getAccordionCategoryValues(
item.FieldValuesAsText[categoryField],
true
);
if (formatted.length) { return formatted; }
}
return getAccordionCategoryValues(item ? item[categoryField] : undefined);
}
private resolveItemCategoryValues(
items: any[], // tslint:disable-line:no-any
categoryField: string,
taxonomyCategory: boolean
): Promise<string[][]> {
const categoriesByItem: string[][] = items.map((item: any): string[] => // tslint:disable-line:no-any
categoryField ? this.itemCategoryValues(item, categoryField, taxonomyCategory) : []);
if (!taxonomyCategory) { return Promise.resolve(categoriesByItem); }
const wssIds: string[] = [];
const seen: { [wssId: string]: boolean } = {};
for (let itemIndex: number = 0; itemIndex < categoriesByItem.length; itemIndex++) {
for (let valueIndex: number = 0; valueIndex < categoriesByItem[itemIndex].length; valueIndex++) {
const value: string = categoriesByItem[itemIndex][valueIndex];
if (/^\d+$/.test(value) && !seen[value]) { seen[value] = true; wssIds.push(value); }
}
}
if (!wssIds.length) { return Promise.resolve(categoriesByItem); }
return this.loadTaxonomyLabels(wssIds).then((labelsByWssId: { [wssId: string]: string }): string[][] =>
categoriesByItem.map((values: string[]): string[] => replaceAccordionCategoryIds(values, labelsByWssId)));
}
private loadTaxonomyLabels(wssIds: string[]): Promise<{ [wssId: string]: string }> {
const missingIds: string[] = wssIds.filter((wssId: string): boolean =>
!this.taxonomyLabelsByWssId[wssId] && !this.missingTaxonomyWssIds[wssId]);
if (!missingIds.length) { return Promise.resolve(this.taxonomyLabelsByWssId); }
const requests: Array<Promise<any[]>> = []; // tslint:disable-line:no-any
for (let offset: number = 0; offset < missingIds.length; offset += 40) {
const chunk: string[] = missingIds.slice(offset, offset + 40);
const filter: string = chunk.map((wssId: string): string => 'Id eq ' + wssId).join(' or ');
const url: string = this.webUrl + "/_api/site/rootweb/lists/getbytitle('TaxonomyHiddenList')/items" +
'?$select=Id,Term&$filter=' + filter + '&$top=40';
requests.push(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): any[] => this.results(payload))); // tslint:disable-line:no-any
}
return Promise.all(requests).then((resultSets: any[][]): { [wssId: string]: string } => { // tslint:disable-line:no-any
for (let resultIndex: number = 0; resultIndex < resultSets.length; resultIndex++) {
const items: any[] = resultSets[resultIndex]; // tslint:disable-line:no-any
for (let itemIndex: number = 0; itemIndex < items.length; itemIndex++) {
const id: string = String(items[itemIndex].Id || items[itemIndex].ID || '');
const term: string = String(items[itemIndex].Term || '').trim();
if (id && term) { this.taxonomyLabelsByWssId[id] = term; }
}
}
for (let i: number = 0; i < missingIds.length; i++) {
if (!this.taxonomyLabelsByWssId[missingIds[i]]) { this.missingTaxonomyWssIds[missingIds[i]] = true; }
}
return this.taxonomyLabelsByWssId;
});
}
private toText(value: any): string { // tslint:disable-line:no-any private toText(value: any): string { // tslint:disable-line:no-any
if (value === undefined || value === null) { return ''; } if (value === undefined || value === null) { return ''; }
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); }
@@ -113,15 +203,12 @@ export class AccordionDataService {
return ''; return '';
} }
private toValues(value: any): string[] { // tslint:disable-line:no-any
if (value === undefined || value === null) { return []; }
const source: any[] = value && Array.isArray(value.results) ? value.results : [value]; // tslint:disable-line:no-any
return source.map((entry: any): string => this.toText(entry).trim()) // tslint:disable-line:no-any
.filter((entry: string): boolean => !!entry);
}
private isSupportedField(type: string): boolean { private isSupportedField(type: string): boolean {
return ['Text', 'Note', 'Choice', 'MultiChoice', 'Number', 'Integer', 'Boolean', 'DateTime', 'Computed'].indexOf(String(type)) >= 0; return ['Text', 'Note', 'Choice', 'MultiChoice', 'Number', 'Integer', 'Boolean', 'DateTime', 'Computed',
'TaxonomyFieldType', 'TaxonomyFieldTypeMulti'].indexOf(String(type)) >= 0;
}
private isTaxonomyType(type: string): boolean {
return type === 'TaxonomyFieldType' || type === 'TaxonomyFieldTypeMulti';
} }
private isFieldName(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value || '')); } 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 isGuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(this.cleanGuid(value)); }
+1 -2
View File
@@ -15,6 +15,5 @@ export interface IAccordionItem {
id: number; id: number;
title: string; title: string;
content: string; content: string;
category: string; categories: string[];
} }
@@ -16,6 +16,7 @@
}, },
"officeFabricIconFontName": "BulletedList2", "officeFabricIconFontName": "BulletedList2",
"properties": { "properties": {
"title": "Accordion",
"listId": "", "listId": "",
"titleField": "Title", "titleField": "Title",
"contentField": "", "contentField": "",
+34 -8
View File
@@ -6,6 +6,7 @@ import {
IPropertyPaneConfiguration, IPropertyPaneConfiguration,
IPropertyPaneDropdownOption, IPropertyPaneDropdownOption,
PropertyPaneDropdown, PropertyPaneDropdown,
PropertyPaneTextField,
PropertyPaneToggle PropertyPaneToggle
} from '@microsoft/sp-webpart-base'; } from '@microsoft/sp-webpart-base';
import * as strings from 'AccordionWebPartStrings'; import * as strings from 'AccordionWebPartStrings';
@@ -21,6 +22,7 @@ import {
import styles from './components/Accordion.module.scss'; import styles from './components/Accordion.module.scss';
export interface IAccordionWebPartProps { export interface IAccordionWebPartProps {
title: string;
listId: string; listId: string;
titleField: string; titleField: string;
contentField: string; contentField: string;
@@ -66,12 +68,14 @@ export default class AccordionWebPart extends BaseClientSideWebPart<IAccordionWe
this.properties.categoryField || '', this.properties.categoryField || '',
this.properties.sortField || this.properties.titleField, this.properties.sortField || this.properties.titleField,
this.properties.sortAscending !== false, this.properties.sortAscending !== false,
this.properties.selectedCategory || '' this.properties.selectedCategory || '',
this.getFieldType(this.properties.categoryField)
).then((items: IAccordionItem[]): void => { ).then((items: IAccordionItem[]): void => {
if (this.disposed || version !== this.requestVersion) { return; } if (this.disposed || version !== this.requestVersion) { return; }
if (!items.length) { this.renderStatus(strings.EmptyMessage, false); return; } if (!items.length) { this.renderStatus(strings.EmptyMessage, false); return; }
const element: React.ReactElement<IAccordionProps> = React.createElement(Accordion, { const element: React.ReactElement<IAccordionProps> = React.createElement(Accordion, {
items: items, items: items,
title: String(this.properties.title || '').trim(),
allowMultiple: this.properties.allowMultiple === true, allowMultiple: this.properties.allowMultiple === true,
initialState: this.normalizeInitialState(this.properties.initialState), initialState: this.normalizeInitialState(this.properties.initialState),
showCategories: this.properties.showCategories !== false && !!this.properties.categoryField, showCategories: this.properties.showCategories !== false && !!this.properties.categoryField,
@@ -126,13 +130,18 @@ export default class AccordionWebPart extends BaseClientSideWebPart<IAccordionWe
} }
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const fieldOptions: IPropertyPaneDropdownOption[] = this.fields.map((field: IAccordionFieldOption): IPropertyPaneDropdownOption => ({ const standardFields: IAccordionFieldOption[] = this.fields.filter((field: IAccordionFieldOption): boolean =>
!this.isTaxonomyField(field));
const fieldOptions: IPropertyPaneDropdownOption[] = standardFields.map((field: IAccordionFieldOption): IPropertyPaneDropdownOption => ({
key: field.internalName, text: field.title + ' (' + field.internalName + ')'
}));
const categoryFieldOptions: IPropertyPaneDropdownOption[] = this.fields.map((field: IAccordionFieldOption): IPropertyPaneDropdownOption => ({
key: field.internalName, text: field.title + ' (' + field.internalName + ')' key: field.internalName, text: field.title + ' (' + field.internalName + ')'
})); }));
const categoryOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.NoCategoryOption }]; const categoryOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.NoCategoryOption }];
const categoryValueOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.AllCategoriesOption }]; const categoryValueOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.AllCategoriesOption }];
const listOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectOption }]; 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 < categoryFieldOptions.length; i++) { categoryOptions.push(categoryFieldOptions[i]); }
for (let i: number = 0; i < this.categories.length; i++) { for (let i: number = 0; i < this.categories.length; i++) {
categoryValueOptions.push({ key: this.categories[i], text: this.categories[i] }); categoryValueOptions.push({ key: this.categories[i], text: this.categories[i] });
} }
@@ -145,6 +154,7 @@ export default class AccordionWebPart extends BaseClientSideWebPart<IAccordionWe
groups: [{ groups: [{
groupName: strings.DataGroupName, groupName: strings.DataGroupName,
groupFields: [ groupFields: [
PropertyPaneTextField('title', { label: strings.WebPartTitleFieldLabel }),
PropertyPaneDropdown('listId', { PropertyPaneDropdown('listId', {
label: strings.ListFieldLabel, label: strings.ListFieldLabel,
options: listOptions, options: listOptions,
@@ -233,7 +243,7 @@ export default class AccordionWebPart extends BaseClientSideWebPart<IAccordionWe
} }
this.loadingCategories = true; this.loadingCategories = true;
this.refreshPropertyPane(); this.refreshPropertyPane();
return this.service.getCategoryValues(listId, categoryField).then((categories: string[]): void => { return this.service.getCategoryValues(listId, categoryField, this.getFieldType(categoryField)).then((categories: string[]): void => {
this.categories = categories; this.categories = categories;
if (categories.indexOf(this.properties.selectedCategory) < 0) { this.properties.selectedCategory = ''; } if (categories.indexOf(this.properties.selectedCategory) < 0) { this.properties.selectedCategory = ''; }
}).catch((error: Error): void => { }).catch((error: Error): void => {
@@ -247,22 +257,38 @@ export default class AccordionWebPart extends BaseClientSideWebPart<IAccordionWe
} }
private applyFieldDefaults(): void { private applyFieldDefaults(): void {
const names: string[] = this.fields.map((field: IAccordionFieldOption): string => field.internalName); const standardFields: IAccordionFieldOption[] = this.fields.filter((field: IAccordionFieldOption): boolean =>
!this.isTaxonomyField(field));
const names: string[] = standardFields.map((field: IAccordionFieldOption): string => field.internalName);
const categoryNames: string[] = this.fields.map((field: IAccordionFieldOption): string => field.internalName);
if (names.indexOf(this.properties.titleField) < 0) { if (names.indexOf(this.properties.titleField) < 0) {
this.properties.titleField = names.indexOf('Title') >= 0 ? 'Title' : (names[0] || ''); this.properties.titleField = names.indexOf('Title') >= 0 ? 'Title' : (names[0] || '');
} }
if (names.indexOf(this.properties.contentField) < 0) { if (names.indexOf(this.properties.contentField) < 0) {
const note: IAccordionFieldOption = this.fields.filter((field: IAccordionFieldOption): boolean => const note: IAccordionFieldOption = standardFields.filter((field: IAccordionFieldOption): boolean =>
field.type === 'Note' && field.internalName !== this.properties.titleField)[0]; field.type === 'Note' && field.internalName !== this.properties.titleField)[0];
this.properties.contentField = note ? note.internalName : this.firstDifferentField(this.properties.titleField); this.properties.contentField = note ? note.internalName : this.firstDifferentField(this.properties.titleField);
} }
if (names.indexOf(this.properties.categoryField) < 0) { this.properties.categoryField = ''; } if (categoryNames.indexOf(this.properties.categoryField) < 0) { this.properties.categoryField = ''; }
if (names.indexOf(this.properties.sortField) < 0) { this.properties.sortField = this.properties.titleField; } if (names.indexOf(this.properties.sortField) < 0) { this.properties.sortField = this.properties.titleField; }
} }
private firstDifferentField(excluded: string): string { private firstDifferentField(excluded: string): string {
for (let i: number = 0; i < this.fields.length; i++) { for (let i: number = 0; i < this.fields.length; i++) {
if (this.fields[i].internalName !== excluded) { return this.fields[i].internalName; } if (this.fields[i].internalName !== excluded && !this.isTaxonomyField(this.fields[i])) {
return this.fields[i].internalName;
}
}
return '';
}
private isTaxonomyField(field: IAccordionFieldOption): boolean {
return field.type === 'TaxonomyFieldType' || field.type === 'TaxonomyFieldTypeMulti';
}
private getFieldType(internalName: string): string {
for (let i: number = 0; i < this.fields.length; i++) {
if (this.fields[i].internalName === internalName) { return this.fields[i].type; }
} }
return ''; return '';
} }
@@ -5,6 +5,7 @@
font-size: 14px; font-size: 14px;
color: "[theme: neutralPrimary, default: #323130]"; color: "[theme: neutralPrimary, default: #323130]";
} }
.webPartHeading { font-size: 24px; font-weight: 300; line-height: 32px; margin: 0 0 14px; }
.category { margin: 0 0 20px; } .category { margin: 0 0 20px; }
.categoryTitle { .categoryTitle {
+19 -12
View File
@@ -22,23 +22,24 @@ export default class Accordion extends React.Component<IAccordionProps, IAccordi
public render(): React.ReactElement<any> { // tslint:disable-line:no-any public render(): React.ReactElement<any> { // tslint:disable-line:no-any
const groups: Array<{ name: string; items: IAccordionItem[] }> = this.groupItems(); const groups: Array<{ name: string; items: IAccordionItem[] }> = this.groupItems();
return <div className={styles.accordion}> return <section className={styles.accordion} aria-label={this.props.title}>
{this.props.title && <h2 className={styles.webPartHeading}>{this.props.title}</h2>}
{groups.map((group, groupIndex) => {groups.map((group, groupIndex) =>
<section className={styles.category} key={'group-' + groupIndex}> <section className={styles.category} key={'group-' + groupIndex}>
{this.props.showCategories && group.name {this.props.showCategories && group.name
? <h2 className={styles.categoryTitle}>{group.name}</h2> ? <h2 className={styles.categoryTitle}>{group.name}</h2>
: undefined} : undefined}
<div>{group.items.map((item: IAccordionItem) => this.renderItem(item))}</div> <div>{group.items.map((item: IAccordionItem) => this.renderItem(item, groupIndex))}</div>
</section> </section>
)} )}
</div>; </section>;
} }
private renderItem(item: IAccordionItem): React.ReactElement<any> { // tslint:disable-line:no-any private renderItem(item: IAccordionItem, groupIndex: number): React.ReactElement<any> { // tslint:disable-line:no-any
const open: boolean = this.state.openIds.indexOf(item.id) >= 0; const open: boolean = this.state.openIds.indexOf(item.id) >= 0;
const buttonId: string = this.instanceId + '-button-' + item.id; const buttonId: string = this.instanceId + '-button-' + groupIndex + '-' + item.id;
const panelId: string = this.instanceId + '-panel-' + item.id; const panelId: string = this.instanceId + '-panel-' + groupIndex + '-' + item.id;
return <div className={styles.item} key={item.id}> return <div className={styles.item} key={groupIndex + '-' + item.id}>
<h3 className={styles.heading}> <h3 className={styles.heading}>
<button type='button' id={buttonId} className={styles.trigger} <button type='button' id={buttonId} className={styles.trigger}
aria-expanded={open} aria-controls={panelId} aria-expanded={open} aria-controls={panelId}
@@ -75,12 +76,18 @@ export default class Accordion extends React.Component<IAccordionProps, IAccordi
const result: Array<{ name: string; items: IAccordionItem[] }> = []; const result: Array<{ name: string; items: IAccordionItem[] }> = [];
const byName: { [name: string]: { name: string; items: IAccordionItem[] } } = {}; const byName: { [name: string]: { name: string; items: IAccordionItem[] } } = {};
for (let i: number = 0; i < this.props.items.length; i++) { for (let i: number = 0; i < this.props.items.length; i++) {
const name: string = this.props.items[i].category || this.props.defaultCategory; const names: string[] = this.props.items[i].categories.length
if (!byName[name]) { ? this.props.items[i].categories
byName[name] = { name: name, items: [] }; : [this.props.defaultCategory];
result.push(byName[name]); for (let nameIndex: number = 0; nameIndex < names.length; nameIndex++) {
const name: string = names[nameIndex];
const groupKey: string = '$' + name.toLocaleLowerCase();
if (!byName[groupKey]) {
byName[groupKey] = { name: name, items: [] };
result.push(byName[groupKey]);
}
byName[groupKey].items.push(this.props.items[i]);
} }
byName[name].items.push(this.props.items[i]);
} }
return result; return result;
} }
@@ -2,6 +2,7 @@ import { IAccordionItem, AccordionInitialState } from '../AccordionTypes';
export interface IAccordionProps { export interface IAccordionProps {
items: IAccordionItem[]; items: IAccordionItem[];
title: string;
allowMultiple: boolean; allowMultiple: boolean;
initialState: AccordionInitialState; initialState: AccordionInitialState;
showCategories: boolean; showCategories: boolean;
@@ -9,4 +10,3 @@ export interface IAccordionProps {
collapseLabel: string; collapseLabel: string;
defaultCategory: string; defaultCategory: string;
} }
+3 -1
View File
@@ -3,6 +3,7 @@ define([], function() {
"PropertyPaneDescription": "Wählen Sie die Liste und Spalten für das Akkordeon aus.", "PropertyPaneDescription": "Wählen Sie die Liste und Spalten für das Akkordeon aus.",
"DataGroupName": "Datenquelle", "DataGroupName": "Datenquelle",
"BehaviorGroupName": "Verhalten", "BehaviorGroupName": "Verhalten",
"WebPartTitleFieldLabel": "WebPart-Titel",
"ListFieldLabel": "Liste", "ListFieldLabel": "Liste",
"TitleFieldLabel": "Spalte für Überschrift", "TitleFieldLabel": "Spalte für Überschrift",
"ContentFieldLabel": "Spalte für Inhalt", "ContentFieldLabel": "Spalte für Inhalt",
@@ -25,6 +26,7 @@ define([], function() {
"LoadErrorMessage": "Die Akkordeon-Einträge konnten nicht geladen werden.", "LoadErrorMessage": "Die Akkordeon-Einträge konnten nicht geladen werden.",
"ExpandLabel": "Aufklappen", "ExpandLabel": "Aufklappen",
"CollapseLabel": "Zuklappen", "CollapseLabel": "Zuklappen",
"DefaultCategory": "Weitere" "DefaultCategory": "Weitere",
"DefaultTitle": "Akkordeon"
}; };
}); });
+3 -1
View File
@@ -3,6 +3,7 @@ define([], function() {
"PropertyPaneDescription": "Select the list and columns used by the accordion.", "PropertyPaneDescription": "Select the list and columns used by the accordion.",
"DataGroupName": "Data source", "DataGroupName": "Data source",
"BehaviorGroupName": "Behavior", "BehaviorGroupName": "Behavior",
"WebPartTitleFieldLabel": "Web part title",
"ListFieldLabel": "List", "ListFieldLabel": "List",
"TitleFieldLabel": "Heading column", "TitleFieldLabel": "Heading column",
"ContentFieldLabel": "Content column", "ContentFieldLabel": "Content column",
@@ -25,6 +26,7 @@ define([], function() {
"LoadErrorMessage": "The accordion entries could not be loaded.", "LoadErrorMessage": "The accordion entries could not be loaded.",
"ExpandLabel": "Expand", "ExpandLabel": "Expand",
"CollapseLabel": "Collapse", "CollapseLabel": "Collapse",
"DefaultCategory": "Other" "DefaultCategory": "Other",
"DefaultTitle": "Accordion"
}; };
}); });
+2
View File
@@ -2,6 +2,7 @@ declare interface IAccordionWebPartStrings {
PropertyPaneDescription: string; PropertyPaneDescription: string;
DataGroupName: string; DataGroupName: string;
BehaviorGroupName: string; BehaviorGroupName: string;
WebPartTitleFieldLabel: string;
ListFieldLabel: string; ListFieldLabel: string;
TitleFieldLabel: string; TitleFieldLabel: string;
ContentFieldLabel: string; ContentFieldLabel: string;
@@ -25,6 +26,7 @@ declare interface IAccordionWebPartStrings {
ExpandLabel: string; ExpandLabel: string;
CollapseLabel: string; CollapseLabel: string;
DefaultCategory: string; DefaultCategory: string;
DefaultTitle: string;
} }
declare module 'AccordionWebPartStrings' { declare module 'AccordionWebPartStrings' {
+34
View File
@@ -0,0 +1,34 @@
import {
getAccordionCategoryValues,
replaceAccordionCategoryIds
} from '../src/webparts/accordion/AccordionCategoryValues';
let passed: number = 0;
function assertValues(actual: string[], expected: string[], message: string): void {
if (actual.join('|') !== expected.join('|')) {
throw new Error(message + ': ' + actual.join('|'));
}
passed++;
}
const firstGuid: string = '11111111-1111-1111-1111-111111111111';
const secondGuid: string = '22222222-2222-2222-2222-222222222222';
assertValues(getAccordionCategoryValues({ Label: 'Human Resources', TermGuid: firstGuid }),
['Human Resources'], 'Single taxonomy value was not parsed.');
assertValues(getAccordionCategoryValues({ results: [
{ Label: 'Policies', TermGuid: firstGuid },
{ Label: 'Benefits', TermGuid: secondGuid }
] }), ['Policies', 'Benefits'], 'Multi-value taxonomy collection was not parsed.');
assertValues(getAccordionCategoryValues('-1;#Policies|' + firstGuid + ';#-1;#Benefits|' + secondGuid),
['Policies', 'Benefits'], 'Serialized SharePoint taxonomy value was not parsed.');
assertValues(getAccordionCategoryValues('Policies|' + firstGuid + ';Benefits|' + secondGuid),
['Policies', 'Benefits'], 'Compact serialized taxonomy value was not parsed.');
assertValues(getAccordionCategoryValues({ results: ['General', 'General', 'News'] }),
['General', 'News'], 'Choice values were not retained and de-duplicated.');
assertValues(getAccordionCategoryValues('Policies; Benefits', true),
['Policies', 'Benefits'], 'FieldValuesAsText taxonomy labels were not split.');
assertValues(replaceAccordionCategoryIds(['1', '2'], { '1': 'Policies', '2': 'Benefits' }),
['Policies', 'Benefits'], 'WSS IDs were not replaced with TaxonomyHiddenList labels.');
console.log('Accordion category value tests passed:', passed);
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"rootDir": "..",
"lib": ["es5", "dom"],
"types": [],
"skipLibCheck": true
},
"files": [
"../src/webparts/accordion/AccordionCategoryValues.ts",
"AccordionCategoryValues.test.ts"
]
}
+23 -2
View File
@@ -18,8 +18,8 @@ var component = read('src/webparts/accordion/components/Accordion.tsx');
var sanitizer = read('src/webparts/accordion/AccordionHtmlSanitizer.ts'); var sanitizer = read('src/webparts/accordion/AccordionHtmlSanitizer.ts');
var styles = read('src/webparts/accordion/components/Accordion.module.scss'); var styles = read('src/webparts/accordion/components/Accordion.module.scss');
assert(packageJson.version === '1.0.0', 'Package version must be 1.0.0.'); assert(packageJson.version === '1.2.1', 'Package version must be 1.2.1.');
assert(solution.version === '1.0.0.0', 'Solution version must be 1.0.0.0.'); assert(solution.version === '1.2.1.0', 'Solution version must be 1.2.1.0.');
assert(solution.includeClientSideAssets === true, 'Client-side assets must be included.'); assert(solution.includeClientSideAssets === true, 'Client-side assets must be included.');
assert(solution.skipFeatureDeployment === true, 'Solution must support central deployment.'); assert(solution.skipFeatureDeployment === true, 'Solution must support central deployment.');
assert(packageJson.name === 'accordion-web-part' && solution.name === 'accordion-web-part' && assert(packageJson.name === 'accordion-web-part' && solution.name === 'accordion-web-part' &&
@@ -29,11 +29,32 @@ assert(json('config/package-solution.json').paths.zippedPackage === 'solution/ac
assert(manifest.componentType === 'WebPart', 'Component must be a web part.'); assert(manifest.componentType === 'WebPart', 'Component must be a web part.');
assert(!!config.bundles['accordion-web-part'], 'Accordion bundle is missing.'); 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('listId'") >= 0, 'Dynamic list selection is missing.');
assert(webPart.indexOf("PropertyPaneTextField('title'") >= 0 &&
component.indexOf('styles.webPartHeading') >= 0,
'Configurable web part title is missing.');
assert(webPart.indexOf("String(this.properties.title || '').trim()") >= 0,
'An empty web part title must remain hidden.');
assert(webPart.indexOf("PropertyPaneDropdown('contentField'") >= 0, 'Dynamic content field 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(webPart.indexOf("PropertyPaneDropdown('categoryField'") >= 0, 'Category field selection is missing.');
assert(webPart.indexOf("PropertyPaneDropdown('selectedCategory'") >= 0, 'Category value filter is missing.'); assert(webPart.indexOf("PropertyPaneDropdown('selectedCategory'") >= 0, 'Category value filter is missing.');
assert(read('src/webparts/accordion/AccordionDataService.ts').indexOf('getCategoryValues') >= 0, assert(read('src/webparts/accordion/AccordionDataService.ts').indexOf('getCategoryValues') >= 0,
'Dynamic category values are missing.'); 'Dynamic category values are missing.');
var dataService = read('src/webparts/accordion/AccordionDataService.ts');
var categoryValues = read('src/webparts/accordion/AccordionCategoryValues.ts');
assert(dataService.indexOf("'TaxonomyFieldType'") >= 0 &&
dataService.indexOf("'TaxonomyFieldTypeMulti'") >= 0 &&
dataService.indexOf('getAccordionCategoryValues') >= 0 &&
categoryValues.indexOf('entry.Label') >= 0 && categoryValues.indexOf("candidate.indexOf(';#')") >= 0,
'Managed Metadata category support is missing.');
assert(dataService.indexOf('FieldValuesAsText/') >= 0 &&
dataService.indexOf('itemCategoryValues') >= 0 && categoryValues.indexOf('splitPlainSemicolon') >= 0,
'Managed Metadata labels must be resolved instead of displaying WSS IDs.');
assert(dataService.indexOf("getbytitle('TaxonomyHiddenList')") >= 0 &&
dataService.indexOf('loadTaxonomyLabels') >= 0 &&
categoryValues.indexOf('replaceAccordionCategoryIds') >= 0,
'Numeric WSS IDs must be resolved through the site collection TaxonomyHiddenList.');
assert(component.indexOf('items[i].categories') >= 0 && component.indexOf('nameIndex') >= 0,
'Multi-value category grouping is missing.');
assert(component.indexOf("aria-expanded={open}") >= 0 && component.indexOf("role='region'") >= 0, assert(component.indexOf("aria-expanded={open}") >= 0 && component.indexOf("role='region'") >= 0,
'Accessible accordion semantics are missing.'); 'Accessible accordion semantics are missing.');
assert(component.indexOf('sanitizeAccordionHtml(item.content)') >= 0, 'Rich text is not sanitized before rendering.'); assert(component.indexOf('sanitizeAccordionHtml(item.content)') >= 0, 'Rich text is not sanitized before rendering.');