From 632d0fa1bc993486bac89a4f96f66fa00e1aea9d Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Mon, 24 Aug 2026 12:57:21 +0200 Subject: [PATCH] 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. --- .gitignore | 18 ++ .yo-rc.json | 11 ++ README.md | 42 ++++ config/config.json | 16 ++ config/copy-assets.json | 1 + config/package-solution.json | 12 ++ config/serve.json | 6 + config/write-manifests.json | 1 + gulpfile.js | 3 + package.json | 33 ++++ src/declarations.d.ts | 4 + src/index.ts | 1 + .../recurringEvents/RecurringEventsCore.ts | 70 +++++++ .../RecurringEventsDataService.ts | 157 +++++++++++++++ .../recurringEvents/RecurringEventsTypes.ts | 28 +++ .../RecurringEventsWebPart.manifest.json | 27 +++ .../recurringEvents/RecurringEventsWebPart.ts | 186 ++++++++++++++++++ .../components/IRecurringEventsProps.ts | 10 + .../components/RecurringEvents.module.scss | 82 ++++++++ .../components/RecurringEvents.tsx | 87 ++++++++ src/webparts/recurringEvents/loc/de-de.js | 29 +++ src/webparts/recurringEvents/loc/en-us.js | 29 +++ .../recurringEvents/loc/myStrings.d.ts | 31 +++ tests/RecurringEventsCore.test.ts | 40 ++++ tests/tsconfig.json | 17 ++ tests/validate-project.js | 41 ++++ tsconfig.json | 17 ++ tslint.json | 33 ++++ 28 files changed, 1032 insertions(+) create mode 100644 .gitignore create mode 100644 .yo-rc.json create mode 100644 README.md create mode 100644 config/config.json create mode 100644 config/copy-assets.json create mode 100644 config/package-solution.json create mode 100644 config/serve.json create mode 100644 config/write-manifests.json create mode 100644 gulpfile.js create mode 100644 package.json create mode 100644 src/declarations.d.ts create mode 100644 src/index.ts create mode 100644 src/webparts/recurringEvents/RecurringEventsCore.ts create mode 100644 src/webparts/recurringEvents/RecurringEventsDataService.ts create mode 100644 src/webparts/recurringEvents/RecurringEventsTypes.ts create mode 100644 src/webparts/recurringEvents/RecurringEventsWebPart.manifest.json create mode 100644 src/webparts/recurringEvents/RecurringEventsWebPart.ts create mode 100644 src/webparts/recurringEvents/components/IRecurringEventsProps.ts create mode 100644 src/webparts/recurringEvents/components/RecurringEvents.module.scss create mode 100644 src/webparts/recurringEvents/components/RecurringEvents.tsx create mode 100644 src/webparts/recurringEvents/loc/de-de.js create mode 100644 src/webparts/recurringEvents/loc/en-us.js create mode 100644 src/webparts/recurringEvents/loc/myStrings.d.ts create mode 100644 tests/RecurringEventsCore.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/validate-project.js create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a69a71 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..107b83b --- /dev/null +++ b/.yo-rc.json @@ -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" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d941b06 --- /dev/null +++ b/README.md @@ -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. diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..681e771 --- /dev/null +++ b/config/config.json @@ -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" + } +} diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..36b0ac6 --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1 @@ +{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", "deployCdnPath": "temp/deploy" } diff --git a/config/package-solution.json b/config/package-solution.json new file mode 100644 index 0000000..bc5aa56 --- /dev/null +++ b/config/package-solution.json @@ -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" } +} diff --git a/config/serve.json b/config/serve.json new file mode 100644 index 0000000..3c539e5 --- /dev/null +++ b/config/serve.json @@ -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" +} diff --git a/config/write-manifests.json b/config/write-manifests.json new file mode 100644 index 0000000..8d0a194 --- /dev/null +++ b/config/write-manifests.json @@ -0,0 +1 @@ +{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "" } diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..4ce2d54 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,3 @@ +'use strict'; +const build = require('@microsoft/sp-build-web'); +build.initialize(require('gulp')); diff --git a/package.json b/package.json new file mode 100644 index 0000000..16474b1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..dc8c2f7 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const styles: { [className: string]: string }; + export = styles; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..984a017 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default as RecurringEventsWebPart } from './webparts/recurringEvents/RecurringEventsWebPart'; diff --git a/src/webparts/recurringEvents/RecurringEventsCore.ts b/src/webparts/recurringEvents/RecurringEventsCore.ts new file mode 100644 index 0000000..83364de --- /dev/null +++ b/src/webparts/recurringEvents/RecurringEventsCore.ts @@ -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(/^\.\//, ''); +} diff --git a/src/webparts/recurringEvents/RecurringEventsDataService.ts b/src/webparts/recurringEvents/RecurringEventsDataService.ts new file mode 100644 index 0000000..e11bc0d --- /dev/null +++ b/src/webparts/recurringEvents/RecurringEventsDataService.ts @@ -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 { + 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 { + 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 { + 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> = months.map((month: Date): Promise => + 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 { + 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 => { + if (response.ok) { return response.text(); } + return response.text().then((text: string): Promise => + 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 => '').join(''); + return '' + + '' + + '' + + '{' + this.cleanGuid(listId) + '}' + + '' + + '' + + '' + + '' + fieldXml + '5000' + + 'TRUE' + this.toIso(month) + '' + + 'TRUETRUE' + + '' + + ''; + } + + private parseRows(xml: string, list: IEventsListOption): IRecurringEvent[] { + const documentNode: Document = new DOMParser().parseFromString(xml, 'text/xml'); + const parseErrors: NodeListOf = documentNode.getElementsByTagName('parsererror'); + if (parseErrors.length) { throw new Error('SharePoint returned invalid calendar XML.'); } + const allElements: NodeListOf = 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 { // tslint:disable-line:no-any + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) + .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any + if (response.ok) { return response.json(); } + return response.text().then((text: string): Promise => // 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(); } +} diff --git a/src/webparts/recurringEvents/RecurringEventsTypes.ts b/src/webparts/recurringEvents/RecurringEventsTypes.ts new file mode 100644 index 0000000..a7f895d --- /dev/null +++ b/src/webparts/recurringEvents/RecurringEventsTypes.ts @@ -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; +} diff --git a/src/webparts/recurringEvents/RecurringEventsWebPart.manifest.json b/src/webparts/recurringEvents/RecurringEventsWebPart.manifest.json new file mode 100644 index 0000000..6999254 --- /dev/null +++ b/src/webparts/recurringEvents/RecurringEventsWebPart.manifest.json @@ -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" + } + }] +} diff --git a/src/webparts/recurringEvents/RecurringEventsWebPart.ts b/src/webparts/recurringEvents/RecurringEventsWebPart.ts new file mode 100644 index 0000000..136bdd7 --- /dev/null +++ b/src/webparts/recurringEvents/RecurringEventsWebPart.ts @@ -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 { + 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 { + this.service = new RecurringEventsDataService(this.context); + return super.onInit().then((): Promise => this.loadLists()) + .then((): Promise => 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 = 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 { + 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 { + 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); + } +} diff --git a/src/webparts/recurringEvents/components/IRecurringEventsProps.ts b/src/webparts/recurringEvents/components/IRecurringEventsProps.ts new file mode 100644 index 0000000..7d3212f --- /dev/null +++ b/src/webparts/recurringEvents/components/IRecurringEventsProps.ts @@ -0,0 +1,10 @@ +import { EventsLayout, IRecurringEvent } from '../RecurringEventsTypes'; + +export interface IRecurringEventsProps { + events: IRecurringEvent[]; + title: string; + layout: EventsLayout; + locale: string; + allDayLabel: string; + recurringLabel: string; +} diff --git a/src/webparts/recurringEvents/components/RecurringEvents.module.scss b/src/webparts/recurringEvents/components/RecurringEvents.module.scss new file mode 100644 index 0000000..d94e3a8 --- /dev/null +++ b/src/webparts/recurringEvents/components/RecurringEvents.module.scss @@ -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; } } diff --git a/src/webparts/recurringEvents/components/RecurringEvents.tsx b/src/webparts/recurringEvents/components/RecurringEvents.tsx new file mode 100644 index 0000000..bd67c92 --- /dev/null +++ b/src/webparts/recurringEvents/components/RecurringEvents.tsx @@ -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 { + public render(): React.ReactElement { + return ( +
+ {this.props.title &&

{this.props.title}

} + {this.props.layout === 'compact' ? this.renderCompact() : this.renderFilmstrip()} +
+ ); + } + + private renderFilmstrip(): JSX.Element { + return ( + + ); + } + + private renderCompact(): JSX.Element { + return ( + + ); + } + + private renderDateBadge(event: IRecurringEvent): JSX.Element { + return ( + + ); + } + + 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, ''); } +} diff --git a/src/webparts/recurringEvents/loc/de-de.js b/src/webparts/recurringEvents/loc/de-de.js new file mode 100644 index 0000000..97bccf5 --- /dev/null +++ b/src/webparts/recurringEvents/loc/de-de.js @@ -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' + }; +}); diff --git a/src/webparts/recurringEvents/loc/en-us.js b/src/webparts/recurringEvents/loc/en-us.js new file mode 100644 index 0000000..a62a781 --- /dev/null +++ b/src/webparts/recurringEvents/loc/en-us.js @@ -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' + }; +}); diff --git a/src/webparts/recurringEvents/loc/myStrings.d.ts b/src/webparts/recurringEvents/loc/myStrings.d.ts new file mode 100644 index 0000000..987900d --- /dev/null +++ b/src/webparts/recurringEvents/loc/myStrings.d.ts @@ -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; +} diff --git a/tests/RecurringEventsCore.test.ts b/tests/RecurringEventsCore.test.ts new file mode 100644 index 0000000..6089784 --- /dev/null +++ b/tests/RecurringEventsCore.test.ts @@ -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); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..22649e3 --- /dev/null +++ b/tests/tsconfig.json @@ -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" + ] +} diff --git a/tests/validate-project.js b/tests/validate-project.js new file mode 100644 index 0000000..166af8f --- /dev/null +++ b/tests/validate-project.js @@ -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('TRUE') >= 0, 'Server-side recurrence expansion is missing.'); +assert(service.indexOf('') >= 0 && service.indexOf('') >= 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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0ad3fe3 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..7dfda34 --- /dev/null +++ b/tslint.json @@ -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 + } +}