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:
Torsten Brendgen
2026-08-23 22:34:12 +02:00
commit bf9781d043
27 changed files with 1009 additions and 0 deletions
+242
View File
@@ -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);
}
}