Add Recurring Events Web Part with data service and UI components

- Implement RecurringEventsDataService for fetching events from SharePoint lists.
- Define types for events and categories in RecurringEventsTypes.
- Create RecurringEventsWebPart to manage properties and render events.
- Develop UI components for displaying events in filmstrip and compact layouts.
- Add localization support for English and German languages.
- Implement tests for core functionalities related to event handling.
- Set up TypeScript and TSLint configurations for the project.
This commit is contained in:
Torsten Brendgen
2026-08-24 12:57:21 +02:00
commit 632d0fa1bc
28 changed files with 1032 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
node_modules/
lib/
dist/
temp/
release/
solution/
sharepoint/solution/
*.sppkg
*.tgz
coverage/
*.resx.ts
*.scss.ts
*.log
npm-debug.log*
.vs/
.idea/
.DS_Store
Thumbs.db
+11
View File
@@ -0,0 +1,11 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.4.1",
"libraryName": "modern-recurring-events-webpart",
"libraryId": "f1082b4a-bbdf-4d31-852d-97e0685c20e2",
"environment": "onprem",
"packageManager": "npm",
"solutionName": "modern-recurring-events-webpart",
"componentType": "webpart"
}
}
+42
View File
@@ -0,0 +1,42 @@
# Modern Recurring Events
SPFx-1.4.1-WebPart für SharePoint Server Subscription Edition. Es orientiert sich optisch am modernen Standard-WebPart **Ereignisse**, zeigt aber zusätzlich die einzelnen Vorkommen von SharePoint-Serienterminen an.
## Funktionsumfang
- Auswahl einer Ereignis-/Kalenderliste des aktuellen Webs
- serverseitige Expansion von Serienterminen über `Lists.asmx` und `ExpandRecurrence=TRUE`
- Berücksichtigung normaler Termine, Serienvorkommen, geänderter Ausnahmen und gelöschter Vorkommen
- Layouts **Filmstreifen** und **Kompakt**
- Filterung nach Kategorie und Zeitraum ab heute
- Anzeige von Datum, Uhrzeit, Ganztägigkeit, Ort, Bild und Serienkennzeichnung
- responsive Darstellung mit SharePoint-Schrift und Themefarben
## Konfiguration
Nach dem Hinzufügen des WebParts im Property Pane einstellen:
1. Titel
2. Ereignisliste
3. optional eine Kategorie
4. Zeitraum von 7, 14, 30, 90 oder 365 Tagen
5. Filmstreifen- oder Kompaktlayout
6. maximale Anzahl der Ereignisse
Die Ereignisliste muss sich im selben Web wie das WebPart befinden. Für den gewählten Zeitraum werden die betroffenen Kalendermonate einzeln abgefragt, damit SharePoint die Serientermine und ihre Ausnahmen korrekt expandiert.
## Build-Umgebung
- Node.js 8.17.0
- npm 6.13.4
- SharePoint Framework 1.4.1
```powershell
npm install
npm test
gulp clean
gulp bundle --ship
gulp package-solution --ship
```
Das Paket wird unter `sharepoint/solution/modern-recurring-events-webpart.sppkg` erzeugt.
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"modern-recurring-events-webpart": {
"components": [{
"entrypoint": "./lib/webparts/recurringEvents/RecurringEventsWebPart.js",
"manifest": "./src/webparts/recurringEvents/RecurringEventsWebPart.manifest.json"
}]
}
},
"externals": {},
"localizedResources": {
"RecurringEventsWebPartStrings": "lib/webparts/recurringEvents/loc/{locale}.js"
}
}
+1
View File
@@ -0,0 +1 @@
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", "deployCdnPath": "temp/deploy" }
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "modern-recurring-events-webpart",
"title": "modern-recurring-events-webpart",
"id": "f1082b4a-bbdf-4d31-852d-97e0685c20e2",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
"paths": { "zippedPackage": "solution/modern-recurring-events-webpart.sppkg" }
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench"
}
+1
View File
@@ -0,0 +1 @@
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "<!-- PATH TO CDN -->" }
+3
View File
@@ -0,0 +1,3 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.initialize(require('gulp'));
+33
View File
@@ -0,0 +1,33 @@
{
"name": "modern-recurring-events-webpart",
"version": "1.0.0",
"private": true,
"main": "lib/index.js",
"engines": { "node": ">=6.9.0 <9.0.0" },
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "tsc --noEmit -p . && tsc -p tests/tsconfig.json && node temp/tests/tests/RecurringEventsCore.test.js && node tests/validate-project.js",
"package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship"
},
"dependencies": {
"@microsoft/sp-core-library": "1.4.1",
"@microsoft/sp-http": "1.4.1",
"@microsoft/sp-office-ui-fabric-core": "1.4.1",
"@microsoft/sp-webpart-base": "1.4.1",
"@types/es6-promise": "0.0.33",
"@types/react": "15.6.6",
"@types/react-dom": "15.5.6",
"@types/webpack-env": "1.13.1",
"react": "15.6.2",
"react-dom": "15.6.2"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.4.1",
"@microsoft/sp-module-interfaces": "1.4.1",
"@microsoft/sp-tslint-rules": "1.4.1",
"@microsoft/sp-webpart-workbench": "1.4.1",
"ajv": "5.2.2",
"gulp": "~3.9.1"
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.module.scss' {
const styles: { [className: string]: string };
export = styles;
}
+1
View File
@@ -0,0 +1 @@
export { default as RecurringEventsWebPart } from './webparts/recurringEvents/RecurringEventsWebPart';
@@ -0,0 +1,70 @@
import { IEventDateRange, IRecurringEvent } from './RecurringEventsTypes';
export function calculateDateRange(days: number, now: Date): IEventDateRange {
const normalizedDays: number = normalizeRangeDays(days);
const start: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const end: Date = new Date(start.getTime());
end.setDate(end.getDate() + normalizedDays);
return { start: start, end: end };
}
export function normalizeRangeDays(value: number): number {
const allowed: number[] = [7, 14, 30, 90, 365];
const parsed: number = Number(value);
return allowed.indexOf(parsed) >= 0 ? parsed : 30;
}
export function normalizeMaxEvents(value: number): number {
const parsed: number = Math.round(Number(value) || 10);
return Math.min(50, Math.max(1, parsed));
}
export function getMonthAnchors(start: Date, end: Date): Date[] {
const result: Date[] = [];
const cursor: Date = new Date(start.getFullYear(), start.getMonth(), 15, 12, 0, 0, 0);
const last: Date = new Date(end.getFullYear(), end.getMonth(), 15, 12, 0, 0, 0);
while (cursor.getTime() <= last.getTime() && result.length < 14) {
result.push(new Date(cursor.getTime()));
cursor.setMonth(cursor.getMonth() + 1);
}
return result;
}
export function filterAndSortEvents(
events: IRecurringEvent[],
range: IEventDateRange,
category: string,
maxEvents: number
): IRecurringEvent[] {
const byKey: { [key: string]: IRecurringEvent } = {};
for (let i: number = 0; i < events.length; i++) {
const event: IRecurringEvent = events[i];
if (event.eventType === 3 || event.end.getTime() < range.start.getTime() || event.start.getTime() >= range.end.getTime()) { continue; }
if (category && event.category !== category) { continue; }
const current: IRecurringEvent = byKey[event.key];
if (!current || event.eventType === 4) { byKey[event.key] = event; }
}
const result: IRecurringEvent[] = [];
const keys: string[] = Object.keys(byKey);
for (let keyIndex: number = 0; keyIndex < keys.length; keyIndex++) { result.push(byKey[keys[keyIndex]]); }
result.sort((left: IRecurringEvent, right: IRecurringEvent): number => {
const dateDifference: number = left.start.getTime() - right.start.getTime();
return dateDifference || left.title.localeCompare(right.title);
});
return result.slice(0, normalizeMaxEvents(maxEvents));
}
export function parseSharePointDate(value: string): Date {
const text: string = String(value || '').trim();
if (!text) { return new Date(NaN); }
const normalized: string = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? text.replace(' ', 'T') + 'Z' : text;
return new Date(normalized);
}
export function normalizeAssetUrl(value: string, webUrl: string): string {
const url: string = String(value || '').trim();
if (!url || /[\u0000-\u001f\u007f]/.test(url) || /^\/\//.test(url)) { return ''; }
if (/^https?:\/\//i.test(url) || url.charAt(0) === '/') { return url; }
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) { return ''; }
return String(webUrl || '').replace(/\/$/, '') + '/' + url.replace(/^\.\//, '');
}
@@ -0,0 +1,157 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
filterAndSortEvents,
getMonthAnchors,
normalizeAssetUrl,
parseSharePointDate
} from './RecurringEventsCore';
import { IEventDateRange, IEventsListOption, IRecurringEvent } from './RecurringEventsTypes';
export class RecurringEventsDataService {
public constructor(private context: WebPartContext) { }
public getLists(): Promise<IEventsListOption[]> {
const url: string = this.webUrl +
'/_api/web/lists?$select=Id,Title,RootFolder/ServerRelativeUrl&$expand=RootFolder&$filter=Hidden eq false and BaseTemplate eq 106&$orderby=Title';
return this.getJson(url).then((payload: any): IEventsListOption[] => // tslint:disable-line:no-any
this.results(payload).map((list: any): IEventsListOption => ({ // tslint:disable-line:no-any
id: this.cleanGuid(list.Id),
title: String(list.Title || list.Id),
serverRelativeUrl: String(list.RootFolder && list.RootFolder.ServerRelativeUrl || '')
})));
}
public getCategories(listId: string): Promise<string[]> {
if (!this.isGuid(listId)) { return Promise.resolve([]); }
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) +
"')/fields/getbyinternalnameortitle('Category')?$select=Choices";
return this.getJson(url).then((payload: any): string[] => { // tslint:disable-line:no-any
const field: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
const values: any = field && field.Choices; // tslint:disable-line:no-any
const choices: any[] = values && Array.isArray(values.results) ? values.results : (Array.isArray(values) ? values : []); // tslint:disable-line:no-any
return choices.map((choice: any): string => String(choice)).filter((choice: string): boolean => !!choice); // tslint:disable-line:no-any
}).catch((): string[] => []);
}
public getEvents(
list: IEventsListOption,
range: IEventDateRange,
category: string,
maxEvents: number
): Promise<IRecurringEvent[]> {
if (!list || !this.isGuid(list.id)) { return Promise.reject(new Error('Invalid events list.')); }
const months: Date[] = getMonthAnchors(range.start, range.end);
const requests: Array<Promise<IRecurringEvent[]>> = months.map((month: Date): Promise<IRecurringEvent[]> =>
this.getExpandedMonth(list, month));
return Promise.all(requests).then((groups: IRecurringEvent[][]): IRecurringEvent[] => {
const events: IRecurringEvent[] = [];
for (let i: number = 0; i < groups.length; i++) { Array.prototype.push.apply(events, groups[i]); }
return filterAndSortEvents(events, range, category, maxEvents);
});
}
private getExpandedMonth(list: IEventsListOption, month: Date): Promise<IRecurringEvent[]> {
const body: string = this.buildSoapEnvelope(list.id, month);
return this.context.spHttpClient.post(this.webUrl + '/_vti_bin/Lists.asmx', SPHttpClient.configurations.v1, {
headers: {
'Accept': 'text/xml',
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': 'http://schemas.microsoft.com/sharepoint/soap/GetListItems'
},
body: body
}).then((response: SPHttpClientResponse): Promise<string> => {
if (response.ok) { return response.text(); }
return response.text().then((text: string): Promise<string> =>
Promise.reject(new Error('Calendar request failed (' + response.status + '): ' + text)));
}).then((xml: string): IRecurringEvent[] => this.parseRows(xml, list));
}
private buildSoapEnvelope(listId: string, month: Date): string {
const fields: string[] = ['ID', 'Title', 'EventDate', 'EndDate', 'Location', 'Description', 'Category',
'fAllDayEvent', 'fRecurrence', 'EventType', 'MasterSeriesItemID', 'RecurrenceID', 'UID', 'BannerUrl'];
const fieldXml: string = fields.map((field: string): string => '<FieldRef Name="' + field + '" />').join('');
return '<?xml version="1.0" encoding="utf-8"?>' +
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
'<soap:Body><GetListItems xmlns="http://schemas.microsoft.com/sharepoint/soap/">' +
'<listName>{' + this.cleanGuid(listId) + '}</listName>' +
'<query><Query><Where><DateRangesOverlap><FieldRef Name="EventDate"/><FieldRef Name="EndDate"/>' +
'<FieldRef Name="RecurrenceID"/><Value Type="DateTime"><Month/></Value></DateRangesOverlap></Where>' +
'<OrderBy><FieldRef Name="EventDate" Ascending="TRUE"/></OrderBy></Query></query>' +
'<viewFields><ViewFields>' + fieldXml + '</ViewFields></viewFields><rowLimit>5000</rowLimit>' +
'<queryOptions><QueryOptions><DateInUtc>TRUE</DateInUtc><CalendarDate>' + this.toIso(month) + '</CalendarDate>' +
'<ExpandRecurrence>TRUE</ExpandRecurrence><RecurrenceOrderBy>TRUE</RecurrenceOrderBy>' +
'<ViewAttributes Scope="RecursiveAll"/></QueryOptions></queryOptions>' +
'</GetListItems></soap:Body></soap:Envelope>';
}
private parseRows(xml: string, list: IEventsListOption): IRecurringEvent[] {
const documentNode: Document = new DOMParser().parseFromString(xml, 'text/xml');
const parseErrors: NodeListOf<Element> = documentNode.getElementsByTagName('parsererror');
if (parseErrors.length) { throw new Error('SharePoint returned invalid calendar XML.'); }
const allElements: NodeListOf<Element> = documentNode.getElementsByTagName('*');
const result: IRecurringEvent[] = [];
for (let i: number = 0; i < allElements.length; i++) {
const element: Element = allElements.item(i) as Element;
if (!element || String(element.localName || element.nodeName).toLowerCase().replace(/^.*:/, '') !== 'row') { continue; }
const event: IRecurringEvent = this.toEvent(element, list);
if (!isNaN(event.start.getTime()) && !isNaN(event.end.getTime())) { result.push(event); }
}
return result;
}
private toEvent(row: Element, list: IEventsListOption): IRecurringEvent {
const id: number = parseInt(this.attribute(row, 'ID'), 10) || 0;
const masterId: number = parseInt(this.attribute(row, 'MasterSeriesItemID'), 10) || id;
const start: Date = parseSharePointDate(this.attribute(row, 'EventDate'));
const end: Date = parseSharePointDate(this.attribute(row, 'EndDate'));
const uid: string = this.attribute(row, 'UID') || String(masterId);
const recurrenceId: string = this.attribute(row, 'RecurrenceID');
const image: string = this.urlField(this.attribute(row, 'BannerUrl'));
return {
key: uid + '|' + (recurrenceId || String(start.getTime())),
id: masterId,
title: this.attribute(row, 'Title'),
start: start,
end: end,
allDay: this.boolValue(this.attribute(row, 'fAllDayEvent')),
location: this.attribute(row, 'Location'),
category: this.attribute(row, 'Category'),
description: this.plainText(this.attribute(row, 'Description')),
imageUrl: normalizeAssetUrl(image, this.webUrl),
detailsUrl: list.serverRelativeUrl.replace(/\/$/, '') + '/DispForm.aspx?ID=' + masterId,
recurring: this.boolValue(this.attribute(row, 'fRecurrence')),
eventType: parseInt(this.attribute(row, 'EventType'), 10) || 0
};
}
private attribute(row: Element, name: string): string { return String(row.getAttribute('ows_' + name) || ''); }
private boolValue(value: string): boolean { return value === '1' || /^true$/i.test(value); }
private urlField(value: string): string {
const separator: number = value.indexOf(', ');
return separator >= 0 ? value.substring(0, separator) : value;
}
private plainText(value: string): string {
const container: HTMLDivElement = document.createElement('div');
container.innerHTML = String(value || '');
return String(container.textContent || container.innerText || '').trim();
}
private toIso(value: Date): string { return value.toISOString().replace(/\.\d{3}Z$/, 'Z'); }
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
private get headers(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } // tslint:disable-line:no-any
private getJson(url: string): Promise<any> { // tslint:disable-line:no-any
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers })
.then((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 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,28 @@
export type EventsLayout = 'filmstrip' | 'compact';
export interface IEventsListOption {
id: string;
title: string;
serverRelativeUrl: string;
}
export interface IRecurringEvent {
key: string;
id: number;
title: string;
start: Date;
end: Date;
allDay: boolean;
location: string;
category: string;
description: string;
imageUrl: string;
detailsUrl: string;
recurring: boolean;
eventType: number;
}
export interface IEventDateRange {
start: Date;
end: Date;
}
@@ -0,0 +1,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "98155235-4169-4e88-ad12-24cc202223b6",
"alias": "RecurringEventsWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other", "de-de": "Weitere" },
"title": { "default": "Recurring Events", "de-de": "Ereignisse mit Serienterminen" },
"description": {
"default": "Displays events and expanded recurring event occurrences in a modern layout.",
"de-de": "Zeigt Ereignisse und expandierte Vorkommen von Serienterminen in einer modernen Darstellung an."
},
"officeFabricIconFontName": "Event",
"properties": {
"title": "Events",
"listId": "",
"category": "",
"rangeDays": 30,
"maxEvents": 10,
"layout": "filmstrip"
}
}]
}
@@ -0,0 +1,186 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
IPropertyPaneDropdownOption,
PropertyPaneDropdown,
PropertyPaneSlider,
PropertyPaneTextField
} from '@microsoft/sp-webpart-base';
import * as strings from 'RecurringEventsWebPartStrings';
import { calculateDateRange, normalizeMaxEvents, normalizeRangeDays } from './RecurringEventsCore';
import { RecurringEventsDataService } from './RecurringEventsDataService';
import { EventsLayout, IEventDateRange, IEventsListOption, IRecurringEvent } from './RecurringEventsTypes';
import RecurringEvents from './components/RecurringEvents';
import { IRecurringEventsProps } from './components/IRecurringEventsProps';
import styles = require('./components/RecurringEvents.module.scss');
export interface IRecurringEventsWebPartProps {
title: string;
listId: string;
category: string;
rangeDays: number;
maxEvents: number;
layout: EventsLayout;
}
export default class RecurringEventsWebPart extends BaseClientSideWebPart<IRecurringEventsWebPartProps> {
private service: RecurringEventsDataService;
private lists: IEventsListOption[] = [];
private categories: string[] = [];
private loadingLists: boolean = false;
private loadingCategories: boolean = false;
private requestVersion: number = 0;
private disposed: boolean = false;
protected onInit(): Promise<void> {
this.service = new RecurringEventsDataService(this.context);
return super.onInit().then((): Promise<void> => this.loadLists())
.then((): Promise<void> => this.properties.listId ? this.loadCategories(this.properties.listId) : Promise.resolve());
}
public render(): void {
const list: IEventsListOption = this.selectedList();
const version: number = ++this.requestVersion;
if (!list) { this.renderStatus(strings.ConfigureMessage, false); return; }
const range: IEventDateRange = calculateDateRange(normalizeRangeDays(this.properties.rangeDays), new Date());
this.renderStatus(strings.LoadingMessage, false);
this.service.getEvents(list, range, this.properties.category || '', normalizeMaxEvents(this.properties.maxEvents))
.then((events: IRecurringEvent[]): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (!events.length) { this.renderStatus(strings.EmptyMessage, false); return; }
const element: React.ReactElement<IRecurringEventsProps> = React.createElement(RecurringEvents, {
events: events,
title: this.properties.title || strings.DefaultTitle,
layout: this.properties.layout === 'compact' ? 'compact' : 'filmstrip',
locale: this.context.pageContext.cultureInfo.currentUICultureName || 'en-us',
allDayLabel: strings.AllDayLabel,
recurringLabel: strings.RecurringLabel
});
ReactDom.render(element, this.domElement);
}).catch((error: Error): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (window.console) { console.error('[RecurringEvents] ' + 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.categories.length && !this.loadingCategories) {
this.loadCategories(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.category = '';
this.categories = [];
this.context.propertyPane.refresh();
this.loadCategories(String(newValue || '')).then((): void => this.render());
}
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const listOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectListOption }];
const categoryOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.AllCategoriesOption }];
for (let i: number = 0; i < this.lists.length; i++) { listOptions.push({ key: this.lists[i].id, text: this.lists[i].title }); }
for (let j: number = 0; j < this.categories.length; j++) {
categoryOptions.push({ key: this.categories[j], text: this.categories[j] });
}
return {
pages: [{
header: { description: strings.PropertyPaneDescription },
groups: [{
groupName: strings.DataGroupName,
groupFields: [
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
PropertyPaneDropdown('listId', {
label: strings.ListFieldLabel,
options: listOptions,
disabled: this.loadingLists
}),
PropertyPaneDropdown('category', {
label: strings.CategoryFieldLabel,
options: categoryOptions,
disabled: !this.properties.listId || this.loadingCategories
}),
PropertyPaneDropdown('rangeDays', {
label: strings.RangeFieldLabel,
options: [
{ key: 7, text: strings.Range7Days },
{ key: 14, text: strings.Range14Days },
{ key: 30, text: strings.Range30Days },
{ key: 90, text: strings.Range90Days },
{ key: 365, text: strings.Range365Days }
]
})
]
}, {
groupName: strings.LayoutGroupName,
groupFields: [
PropertyPaneDropdown('layout', {
label: strings.LayoutFieldLabel,
options: [
{ key: 'filmstrip', text: strings.FilmstripLayout },
{ key: 'compact', text: strings.CompactLayout }
]
}),
PropertyPaneSlider('maxEvents', { label: strings.MaxEventsFieldLabel, min: 1, max: 50, step: 1 })
]
}]
}]
};
}
private loadLists(): Promise<void> {
this.loadingLists = true;
this.refreshPropertyPane();
return this.service.getLists().then((lists: IEventsListOption[]): void => {
this.lists = lists;
if (this.properties.listId && !this.selectedList()) { this.properties.listId = ''; this.properties.category = ''; }
}).catch((error: Error): void => {
this.lists = [];
if (window.console) { console.error('[RecurringEvents] Event lists could not be loaded.', error); }
}).then((): void => { this.loadingLists = false; this.refreshPropertyPane(); });
}
private loadCategories(listId: string): Promise<void> {
if (!listId) { this.categories = []; return Promise.resolve(); }
this.loadingCategories = true;
this.refreshPropertyPane();
return this.service.getCategories(listId).then((categories: string[]): void => {
this.categories = categories;
if (categories.indexOf(this.properties.category) < 0) { this.properties.category = ''; }
}).catch((): void => { this.categories = []; this.properties.category = ''; })
.then((): void => { this.loadingCategories = false; this.refreshPropertyPane(); });
}
private selectedList(): IEventsListOption {
for (let i: number = 0; i < this.lists.length; i++) {
if (this.lists[i].id === String(this.properties.listId || '').replace(/[{}]/g, '').toLowerCase()) { return this.lists[i]; }
}
return undefined as IEventsListOption;
}
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,10 @@
import { EventsLayout, IRecurringEvent } from '../RecurringEventsTypes';
export interface IRecurringEventsProps {
events: IRecurringEvent[];
title: string;
layout: EventsLayout;
locale: string;
allDayLabel: string;
recurringLabel: string;
}
@@ -0,0 +1,82 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.recurringEvents {
color: "[theme:bodyText, default:#323130]";
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
}
.heading {
color: "[theme:bodyText, default:#323130]";
font-size: 24px;
font-weight: 300;
line-height: 32px;
margin: 0 0 20px;
}
.filmstrip {
display: flex;
gap: 16px;
list-style: none;
margin: 0;
overflow-x: auto;
padding: 0 0 8px;
scroll-snap-type: x proximity;
}
.card {
background: "[theme:white, default:#ffffff]";
border: 1px solid "[theme:neutralLight, default:#edebe9]";
box-sizing: border-box;
flex: 0 0 calc((100% - 32px) / 3);
min-width: 240px;
scroll-snap-align: start;
}
.cardLink, .compactLink {
color: "[theme:bodyText, default:#323130]";
display: flex;
text-decoration: none;
&:hover { color: "[theme:bodyText, default:#323130]"; }
&:focus { outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: -2px; }
}
.cardLink { display: block; height: 100%; }
.image, .imageFallback {
background-color: "[theme:themePrimary, default:#0078d4]";
background-position: center;
background-repeat: no-repeat;
background-size: cover;
display: block;
height: 150px;
position: relative;
}
.imageFallback { background: linear-gradient(135deg, "[theme:themePrimary, default:#0078d4]", "[theme:themeDark, default:#005a9e]"); }
.calendarIcon { color: #fff; font-size: 52px; left: 20px; opacity: .8; position: absolute; top: 16px; }
.cardBody { display: flex; min-height: 112px; padding: 16px; }
.cardContent, .compactContent { display: block; min-width: 0; }
.dateBadge {
color: "[theme:themePrimary, default:#0078d4]";
display: flex;
flex: 0 0 48px;
flex-direction: column;
margin-right: 14px;
text-align: center;
}
.month { font-size: 12px; font-weight: 600; line-height: 18px; text-transform: uppercase; }
.day { color: "[theme:bodyText, default:#323130]"; font-size: 28px; font-weight: 300; line-height: 32px; }
.title { display: block; font-size: 17px; font-weight: 600; line-height: 22px; overflow-wrap: break-word; }
.dateLine, .location { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 13px; line-height: 19px; margin-top: 4px; }
.recurring { color: "[theme:themePrimary, default:#0078d4]"; display: block; font-size: 12px; margin-top: 6px; }
.compact { list-style: none; margin: 0; padding: 0; }
.compactItem { border-bottom: 1px solid "[theme:neutralLight, default:#edebe9]"; }
.compactLink { align-items: center; min-height: 72px; padding: 12px 8px; }
.compactContent { flex: 1 1 auto; }
.recurringIcon { color: "[theme:themePrimary, default:#0078d4]"; flex: 0 0 30px; font-size: 20px; text-align: center; }
.status { color: "[theme:bodyText, default:#323130]"; padding: 16px; }
.error { color: "[theme:errorText, default:#a80000]"; padding: 16px; }
@media (max-width: 900px) { .card { flex-basis: calc((100% - 16px) / 2); } }
@media (max-width: 560px) { .card { flex-basis: 88%; } .heading { font-size: 21px; margin-bottom: 14px; } }
@@ -0,0 +1,87 @@
import * as React from 'react';
import { IRecurringEvent } from '../RecurringEventsTypes';
import styles = require('./RecurringEvents.module.scss');
import { IRecurringEventsProps } from './IRecurringEventsProps';
export default class RecurringEvents extends React.Component<IRecurringEventsProps, {}> {
public render(): React.ReactElement<IRecurringEventsProps> {
return (
<section className={styles.recurringEvents} aria-label={this.props.title}>
{this.props.title && <h2 className={styles.heading}>{this.props.title}</h2>}
{this.props.layout === 'compact' ? this.renderCompact() : this.renderFilmstrip()}
</section>
);
}
private renderFilmstrip(): JSX.Element {
return (
<ul className={styles.filmstrip} role='list'>
{this.props.events.map((event: IRecurringEvent): JSX.Element => (
<li className={styles.card} key={event.key}>
<a className={styles.cardLink} href={event.detailsUrl} aria-label={this.eventLabel(event)}>
<span className={event.imageUrl ? styles.image : styles.imageFallback}
style={event.imageUrl ? { backgroundImage: 'url("' + this.cssUrl(event.imageUrl) + '")' } : {}}>
{!event.imageUrl && <span className={styles.calendarIcon} aria-hidden='true'>&#9638;</span>}
</span>
<span className={styles.cardBody}>
{this.renderDateBadge(event)}
<span className={styles.cardContent}>
<span className={styles.title}>{event.title}</span>
<span className={styles.dateLine}>{this.formatDateLine(event)}</span>
{event.location && <span className={styles.location}>{event.location}</span>}
{event.recurring && <span className={styles.recurring}>{this.props.recurringLabel}</span>}
</span>
</span>
</a>
</li>
))}
</ul>
);
}
private renderCompact(): JSX.Element {
return (
<ul className={styles.compact} role='list'>
{this.props.events.map((event: IRecurringEvent): JSX.Element => (
<li className={styles.compactItem} key={event.key}>
<a className={styles.compactLink} href={event.detailsUrl} aria-label={this.eventLabel(event)}>
{this.renderDateBadge(event)}
<span className={styles.compactContent}>
<span className={styles.title}>{event.title}</span>
<span className={styles.dateLine}>{this.formatDateLine(event)}</span>
{event.location && <span className={styles.location}>{event.location}</span>}
</span>
{event.recurring && <span className={styles.recurringIcon} title={this.props.recurringLabel} aria-hidden='true'>&#8635;</span>}
</a>
</li>
))}
</ul>
);
}
private renderDateBadge(event: IRecurringEvent): JSX.Element {
return (
<span className={styles.dateBadge} aria-hidden='true'>
<span className={styles.month}>{event.start.toLocaleDateString(this.props.locale, { month: 'short' }).replace('.', '')}</span>
<span className={styles.day}>{event.start.getDate()}</span>
</span>
);
}
private formatDateLine(event: IRecurringEvent): string {
const date: string = event.start.toLocaleDateString(this.props.locale, { weekday: 'short', day: '2-digit', month: 'long' });
if (event.allDay) { return date + ' · ' + this.props.allDayLabel; }
const startTime: string = event.start.toLocaleTimeString(this.props.locale, { hour: '2-digit', minute: '2-digit' });
const endTime: string = event.end.toLocaleTimeString(this.props.locale, { hour: '2-digit', minute: '2-digit' });
return date + ' · ' + startTime + '–' + endTime;
}
private eventLabel(event: IRecurringEvent): string {
const parts: string[] = [event.title, this.formatDateLine(event)];
if (event.location) { parts.push(event.location); }
if (event.recurring) { parts.push(this.props.recurringLabel); }
return parts.join('. ');
}
private cssUrl(value: string): string { return String(value || '').replace(/["'()\\\r\n]/g, ''); }
}
+29
View File
@@ -0,0 +1,29 @@
define([], function() {
return {
PropertyPaneDescription: 'Zeigt normale Ereignisse und einzelne Vorkommen von Serienterminen an.',
DataGroupName: 'Ereignisquelle',
LayoutGroupName: 'Darstellung',
TitleFieldLabel: 'WebPart-Titel',
ListFieldLabel: 'Ereignisliste',
CategoryFieldLabel: 'Kategorie',
RangeFieldLabel: 'Zeitraum ab heute',
LayoutFieldLabel: 'Layout',
MaxEventsFieldLabel: 'Maximale Anzahl Ereignisse',
SelectListOption: 'Liste auswählen',
AllCategoriesOption: 'Alle Kategorien',
Range7Days: 'Nächste 7 Tage',
Range14Days: 'Nächste 14 Tage',
Range30Days: 'Nächste 30 Tage',
Range90Days: 'Nächste 3 Monate',
Range365Days: 'Nächstes Jahr',
FilmstripLayout: 'Filmstreifen',
CompactLayout: 'Kompakt',
ConfigureMessage: 'Bearbeiten Sie das WebPart und wählen Sie eine Ereignisliste aus.',
LoadingMessage: 'Ereignisse werden geladen…',
EmptyMessage: 'Im gewählten Zeitraum wurden keine Ereignisse gefunden.',
LoadErrorMessage: 'Die Ereignisse konnten nicht geladen werden.',
DefaultTitle: 'Ereignisse',
AllDayLabel: 'Ganztägig',
RecurringLabel: 'Serientermin'
};
});
+29
View File
@@ -0,0 +1,29 @@
define([], function() {
return {
PropertyPaneDescription: 'Displays regular events and individual occurrences of recurring events.',
DataGroupName: 'Event source',
LayoutGroupName: 'Layout',
TitleFieldLabel: 'Web part title',
ListFieldLabel: 'Events list',
CategoryFieldLabel: 'Category',
RangeFieldLabel: 'Date range from today',
LayoutFieldLabel: 'Layout',
MaxEventsFieldLabel: 'Maximum number of events',
SelectListOption: 'Select a list',
AllCategoriesOption: 'All categories',
Range7Days: 'Next 7 days',
Range14Days: 'Next 14 days',
Range30Days: 'Next 30 days',
Range90Days: 'Next 3 months',
Range365Days: 'Next year',
FilmstripLayout: 'Filmstrip',
CompactLayout: 'Compact',
ConfigureMessage: 'Edit the web part and select an Events list.',
LoadingMessage: 'Loading events…',
EmptyMessage: 'No events were found in the selected date range.',
LoadErrorMessage: 'The events could not be loaded.',
DefaultTitle: 'Events',
AllDayLabel: 'All day',
RecurringLabel: 'Recurring event'
};
});
+31
View File
@@ -0,0 +1,31 @@
declare interface IRecurringEventsWebPartStrings {
PropertyPaneDescription: string;
DataGroupName: string;
LayoutGroupName: string;
TitleFieldLabel: string;
ListFieldLabel: string;
CategoryFieldLabel: string;
RangeFieldLabel: string;
LayoutFieldLabel: string;
MaxEventsFieldLabel: string;
SelectListOption: string;
AllCategoriesOption: string;
Range7Days: string;
Range14Days: string;
Range30Days: string;
Range90Days: string;
Range365Days: string;
FilmstripLayout: string;
CompactLayout: string;
ConfigureMessage: string;
LoadingMessage: string;
EmptyMessage: string;
LoadErrorMessage: string;
DefaultTitle: string;
AllDayLabel: string;
RecurringLabel: string;
}
declare module 'RecurringEventsWebPartStrings' {
const strings: IRecurringEventsWebPartStrings;
export = strings;
}
+40
View File
@@ -0,0 +1,40 @@
import {
calculateDateRange,
filterAndSortEvents,
getMonthAnchors,
normalizeAssetUrl,
normalizeMaxEvents,
normalizeRangeDays,
parseSharePointDate
} from '../src/webparts/recurringEvents/RecurringEventsCore';
import { IEventDateRange, IRecurringEvent } from '../src/webparts/recurringEvents/RecurringEventsTypes';
declare const console: { log(message: string): void };
let passed: number = 0;
function assert(condition: boolean, message: string): void { if (!condition) { throw new Error(message); } passed++; }
function event(key: string, day: number, type: number, category: string): IRecurringEvent {
return {
key: key, id: 1, title: key + '-' + type,
start: new Date(2026, 7, day, 10), end: new Date(2026, 7, day, 11), allDay: false,
location: '', category: category, description: '', imageUrl: '', detailsUrl: '', recurring: true, eventType: type
};
}
const range: IEventDateRange = calculateDateRange(30, new Date(2026, 7, 10, 15));
assert(range.start.getHours() === 0 && range.start.getDate() === 10, 'Range must start at local midnight.');
assert(range.end.getDate() === 9 && range.end.getMonth() === 8, '30-day range end is invalid.');
assert(normalizeRangeDays(14) === 14 && normalizeRangeDays(13) === 30, 'Range values must be constrained.');
assert(normalizeMaxEvents(0) === 10 && normalizeMaxEvents(80) === 50, 'Maximum event count must be constrained.');
assert(getMonthAnchors(new Date(2026, 6, 1), new Date(2026, 8, 1)).length === 3, 'Every affected month must be queried.');
assert(parseSharePointDate('2026-08-24 10:30:00').getUTCMinutes() === 30, 'SOAP UTC date must be parsed as UTC.');
assert(normalizeAssetUrl('javascript:alert(1)', 'http://portal') === '', 'Unsafe image URL must be rejected.');
assert(normalizeAssetUrl('/SiteAssets/event.jpg', 'http://portal') === '/SiteAssets/event.jpg', 'Relative image URL must be retained.');
const filtered: IRecurringEvent[] = filterAndSortEvents([
event('series|1', 12, 1, 'Work'), event('series|1', 12, 4, 'Work'),
event('deleted', 13, 3, 'Work'), event('other', 11, 0, 'Private')
], range, 'Work', 10);
assert(filtered.length === 1, 'Category, deleted occurrences and duplicates must be filtered.');
assert(filtered[0].eventType === 4, 'A modified occurrence must override its generated occurrence.');
console.log('Recurring events core tests passed: ' + passed);
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"rootDir": "..",
"lib": ["es5", "dom", "es2015.promise"],
"types": [],
"skipLibCheck": true
},
"files": [
"../src/webparts/recurringEvents/RecurringEventsTypes.ts",
"../src/webparts/recurringEvents/RecurringEventsCore.ts",
"RecurringEventsCore.test.ts"
]
}
+41
View File
@@ -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 packageSolution = json('config/package-solution.json');
var config = json('config/config.json');
var manifest = json('src/webparts/recurringEvents/RecurringEventsWebPart.manifest.json');
var service = read('src/webparts/recurringEvents/RecurringEventsDataService.ts');
var webPart = read('src/webparts/recurringEvents/RecurringEventsWebPart.ts');
var component = read('src/webparts/recurringEvents/components/RecurringEvents.tsx');
var styles = read('src/webparts/recurringEvents/components/RecurringEvents.module.scss');
assert(packageJson.name === 'modern-recurring-events-webpart', 'Package name is inconsistent.');
assert(packageSolution.solution.name === packageJson.name && packageSolution.solution.title === packageJson.name,
'Solution name is inconsistent.');
assert(packageSolution.paths.zippedPackage === 'solution/modern-recurring-events-webpart.sppkg', 'Package file name is inconsistent.');
assert(packageSolution.solution.includeClientSideAssets === true && packageSolution.solution.skipFeatureDeployment === true,
'Central deployment is not configured.');
assert(!!config.bundles['modern-recurring-events-webpart'], 'Bundle name is inconsistent.');
assert(manifest.componentType === 'WebPart' && manifest.alias === 'RecurringEventsWebPart', 'Manifest is invalid.');
assert(service.indexOf('BaseTemplate eq 106') >= 0, 'Events-list filtering is missing.');
assert(service.indexOf('<ExpandRecurrence>TRUE</ExpandRecurrence>') >= 0, 'Server-side recurrence expansion is missing.');
assert(service.indexOf('<CalendarDate>') >= 0 && service.indexOf('<Month/>') >= 0, 'Calendar month expansion is incomplete.');
assert(service.indexOf('MasterSeriesItemID') >= 0 && service.indexOf('RecurrenceID') >= 0, 'Series metadata is missing.');
assert(webPart.indexOf("{ key: 'filmstrip'") >= 0 && webPart.indexOf("{ key: 'compact'") >= 0,
'Filmstrip and compact layouts are not configurable.');
assert(webPart.indexOf("PropertyPaneDropdown('category'") >= 0 && webPart.indexOf("PropertyPaneDropdown('rangeDays'") >= 0,
'Event filtering controls are missing.');
assert(component.indexOf("role='list'") >= 0 && component.indexOf('aria-label={this.eventLabel(event)}') >= 0,
'Accessible event semantics are missing.');
['bodyText', 'white', 'neutralLight', 'themePrimary', 'themeDark', 'focusBorder', 'errorText'].forEach(function(slot) {
assert(styles.indexOf('[theme:' + slot + ', default:') >= 0, 'SharePoint theme slot is missing: ' + slot);
});
console.log('Modern Recurring Events project validation passed: ' + passed);
+17
View File
@@ -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"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+33
View File
@@ -0,0 +1,33 @@
{
"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-debugger": true,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-string-literal": false,
"no-switch-case-fall-through": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-var-keyword": true,
"no-unnecessary-semicolons": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}