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
+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);