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:
+18
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", "deployCdnPath": "temp/deploy" }
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "<!-- PATH TO CDN -->" }
|
||||
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
const build = require('@microsoft/sp-build-web');
|
||||
build.initialize(require('gulp'));
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.scss' {
|
||||
const styles: { [className: string]: string };
|
||||
export = styles;
|
||||
}
|
||||
@@ -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'>▦</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'>↻</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, ''); }
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user