Add Recurring Events Web Part with data service and UI components

- Implement RecurringEventsDataService for fetching events from SharePoint lists.
- Define types for events and categories in RecurringEventsTypes.
- Create RecurringEventsWebPart to manage properties and render events.
- Develop UI components for displaying events in filmstrip and compact layouts.
- Add localization support for English and German languages.
- Implement tests for core functionalities related to event handling.
- Set up TypeScript and TSLint configurations for the project.
This commit is contained in:
Torsten Brendgen
2026-08-24 12:57:21 +02:00
commit 632d0fa1bc
28 changed files with 1032 additions and 0 deletions
@@ -0,0 +1,70 @@
import { IEventDateRange, IRecurringEvent } from './RecurringEventsTypes';
export function calculateDateRange(days: number, now: Date): IEventDateRange {
const normalizedDays: number = normalizeRangeDays(days);
const start: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const end: Date = new Date(start.getTime());
end.setDate(end.getDate() + normalizedDays);
return { start: start, end: end };
}
export function normalizeRangeDays(value: number): number {
const allowed: number[] = [7, 14, 30, 90, 365];
const parsed: number = Number(value);
return allowed.indexOf(parsed) >= 0 ? parsed : 30;
}
export function normalizeMaxEvents(value: number): number {
const parsed: number = Math.round(Number(value) || 10);
return Math.min(50, Math.max(1, parsed));
}
export function getMonthAnchors(start: Date, end: Date): Date[] {
const result: Date[] = [];
const cursor: Date = new Date(start.getFullYear(), start.getMonth(), 15, 12, 0, 0, 0);
const last: Date = new Date(end.getFullYear(), end.getMonth(), 15, 12, 0, 0, 0);
while (cursor.getTime() <= last.getTime() && result.length < 14) {
result.push(new Date(cursor.getTime()));
cursor.setMonth(cursor.getMonth() + 1);
}
return result;
}
export function filterAndSortEvents(
events: IRecurringEvent[],
range: IEventDateRange,
category: string,
maxEvents: number
): IRecurringEvent[] {
const byKey: { [key: string]: IRecurringEvent } = {};
for (let i: number = 0; i < events.length; i++) {
const event: IRecurringEvent = events[i];
if (event.eventType === 3 || event.end.getTime() < range.start.getTime() || event.start.getTime() >= range.end.getTime()) { continue; }
if (category && event.category !== category) { continue; }
const current: IRecurringEvent = byKey[event.key];
if (!current || event.eventType === 4) { byKey[event.key] = event; }
}
const result: IRecurringEvent[] = [];
const keys: string[] = Object.keys(byKey);
for (let keyIndex: number = 0; keyIndex < keys.length; keyIndex++) { result.push(byKey[keys[keyIndex]]); }
result.sort((left: IRecurringEvent, right: IRecurringEvent): number => {
const dateDifference: number = left.start.getTime() - right.start.getTime();
return dateDifference || left.title.localeCompare(right.title);
});
return result.slice(0, normalizeMaxEvents(maxEvents));
}
export function parseSharePointDate(value: string): Date {
const text: string = String(value || '').trim();
if (!text) { return new Date(NaN); }
const normalized: string = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? text.replace(' ', 'T') + 'Z' : text;
return new Date(normalized);
}
export function normalizeAssetUrl(value: string, webUrl: string): string {
const url: string = String(value || '').trim();
if (!url || /[\u0000-\u001f\u007f]/.test(url) || /^\/\//.test(url)) { return ''; }
if (/^https?:\/\//i.test(url) || url.charAt(0) === '/') { return url; }
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) { return ''; }
return String(webUrl || '').replace(/\/$/, '') + '/' + url.replace(/^\.\//, '');
}
@@ -0,0 +1,157 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
filterAndSortEvents,
getMonthAnchors,
normalizeAssetUrl,
parseSharePointDate
} from './RecurringEventsCore';
import { IEventDateRange, IEventsListOption, IRecurringEvent } from './RecurringEventsTypes';
export class RecurringEventsDataService {
public constructor(private context: WebPartContext) { }
public getLists(): Promise<IEventsListOption[]> {
const url: string = this.webUrl +
'/_api/web/lists?$select=Id,Title,RootFolder/ServerRelativeUrl&$expand=RootFolder&$filter=Hidden eq false and BaseTemplate eq 106&$orderby=Title';
return this.getJson(url).then((payload: any): IEventsListOption[] => // tslint:disable-line:no-any
this.results(payload).map((list: any): IEventsListOption => ({ // tslint:disable-line:no-any
id: this.cleanGuid(list.Id),
title: String(list.Title || list.Id),
serverRelativeUrl: String(list.RootFolder && list.RootFolder.ServerRelativeUrl || '')
})));
}
public getCategories(listId: string): Promise<string[]> {
if (!this.isGuid(listId)) { return Promise.resolve([]); }
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.cleanGuid(listId) +
"')/fields/getbyinternalnameortitle('Category')?$select=Choices";
return this.getJson(url).then((payload: any): string[] => { // tslint:disable-line:no-any
const field: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
const values: any = field && field.Choices; // tslint:disable-line:no-any
const choices: any[] = values && Array.isArray(values.results) ? values.results : (Array.isArray(values) ? values : []); // tslint:disable-line:no-any
return choices.map((choice: any): string => String(choice)).filter((choice: string): boolean => !!choice); // tslint:disable-line:no-any
}).catch((): string[] => []);
}
public getEvents(
list: IEventsListOption,
range: IEventDateRange,
category: string,
maxEvents: number
): Promise<IRecurringEvent[]> {
if (!list || !this.isGuid(list.id)) { return Promise.reject(new Error('Invalid events list.')); }
const months: Date[] = getMonthAnchors(range.start, range.end);
const requests: Array<Promise<IRecurringEvent[]>> = months.map((month: Date): Promise<IRecurringEvent[]> =>
this.getExpandedMonth(list, month));
return Promise.all(requests).then((groups: IRecurringEvent[][]): IRecurringEvent[] => {
const events: IRecurringEvent[] = [];
for (let i: number = 0; i < groups.length; i++) { Array.prototype.push.apply(events, groups[i]); }
return filterAndSortEvents(events, range, category, maxEvents);
});
}
private getExpandedMonth(list: IEventsListOption, month: Date): Promise<IRecurringEvent[]> {
const body: string = this.buildSoapEnvelope(list.id, month);
return this.context.spHttpClient.post(this.webUrl + '/_vti_bin/Lists.asmx', SPHttpClient.configurations.v1, {
headers: {
'Accept': 'text/xml',
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': 'http://schemas.microsoft.com/sharepoint/soap/GetListItems'
},
body: body
}).then((response: SPHttpClientResponse): Promise<string> => {
if (response.ok) { return response.text(); }
return response.text().then((text: string): Promise<string> =>
Promise.reject(new Error('Calendar request failed (' + response.status + '): ' + text)));
}).then((xml: string): IRecurringEvent[] => this.parseRows(xml, list));
}
private buildSoapEnvelope(listId: string, month: Date): string {
const fields: string[] = ['ID', 'Title', 'EventDate', 'EndDate', 'Location', 'Description', 'Category',
'fAllDayEvent', 'fRecurrence', 'EventType', 'MasterSeriesItemID', 'RecurrenceID', 'UID', 'BannerUrl'];
const fieldXml: string = fields.map((field: string): string => '<FieldRef Name="' + field + '" />').join('');
return '<?xml version="1.0" encoding="utf-8"?>' +
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
'<soap:Body><GetListItems xmlns="http://schemas.microsoft.com/sharepoint/soap/">' +
'<listName>{' + this.cleanGuid(listId) + '}</listName>' +
'<query><Query><Where><DateRangesOverlap><FieldRef Name="EventDate"/><FieldRef Name="EndDate"/>' +
'<FieldRef Name="RecurrenceID"/><Value Type="DateTime"><Month/></Value></DateRangesOverlap></Where>' +
'<OrderBy><FieldRef Name="EventDate" Ascending="TRUE"/></OrderBy></Query></query>' +
'<viewFields><ViewFields>' + fieldXml + '</ViewFields></viewFields><rowLimit>5000</rowLimit>' +
'<queryOptions><QueryOptions><DateInUtc>TRUE</DateInUtc><CalendarDate>' + this.toIso(month) + '</CalendarDate>' +
'<ExpandRecurrence>TRUE</ExpandRecurrence><RecurrenceOrderBy>TRUE</RecurrenceOrderBy>' +
'<ViewAttributes Scope="RecursiveAll"/></QueryOptions></queryOptions>' +
'</GetListItems></soap:Body></soap:Envelope>';
}
private parseRows(xml: string, list: IEventsListOption): IRecurringEvent[] {
const documentNode: Document = new DOMParser().parseFromString(xml, 'text/xml');
const parseErrors: NodeListOf<Element> = documentNode.getElementsByTagName('parsererror');
if (parseErrors.length) { throw new Error('SharePoint returned invalid calendar XML.'); }
const allElements: NodeListOf<Element> = documentNode.getElementsByTagName('*');
const result: IRecurringEvent[] = [];
for (let i: number = 0; i < allElements.length; i++) {
const element: Element = allElements.item(i) as Element;
if (!element || String(element.localName || element.nodeName).toLowerCase().replace(/^.*:/, '') !== 'row') { continue; }
const event: IRecurringEvent = this.toEvent(element, list);
if (!isNaN(event.start.getTime()) && !isNaN(event.end.getTime())) { result.push(event); }
}
return result;
}
private toEvent(row: Element, list: IEventsListOption): IRecurringEvent {
const id: number = parseInt(this.attribute(row, 'ID'), 10) || 0;
const masterId: number = parseInt(this.attribute(row, 'MasterSeriesItemID'), 10) || id;
const start: Date = parseSharePointDate(this.attribute(row, 'EventDate'));
const end: Date = parseSharePointDate(this.attribute(row, 'EndDate'));
const uid: string = this.attribute(row, 'UID') || String(masterId);
const recurrenceId: string = this.attribute(row, 'RecurrenceID');
const image: string = this.urlField(this.attribute(row, 'BannerUrl'));
return {
key: uid + '|' + (recurrenceId || String(start.getTime())),
id: masterId,
title: this.attribute(row, 'Title'),
start: start,
end: end,
allDay: this.boolValue(this.attribute(row, 'fAllDayEvent')),
location: this.attribute(row, 'Location'),
category: this.attribute(row, 'Category'),
description: this.plainText(this.attribute(row, 'Description')),
imageUrl: normalizeAssetUrl(image, this.webUrl),
detailsUrl: list.serverRelativeUrl.replace(/\/$/, '') + '/DispForm.aspx?ID=' + masterId,
recurring: this.boolValue(this.attribute(row, 'fRecurrence')),
eventType: parseInt(this.attribute(row, 'EventType'), 10) || 0
};
}
private attribute(row: Element, name: string): string { return String(row.getAttribute('ows_' + name) || ''); }
private boolValue(value: string): boolean { return value === '1' || /^true$/i.test(value); }
private urlField(value: string): string {
const separator: number = value.indexOf(', ');
return separator >= 0 ? value.substring(0, separator) : value;
}
private plainText(value: string): string {
const container: HTMLDivElement = document.createElement('div');
container.innerHTML = String(value || '');
return String(container.textContent || container.innerText || '').trim();
}
private toIso(value: Date): string { return value.toISOString().replace(/\.\d{3}Z$/, 'Z'); }
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
private get headers(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } // tslint:disable-line:no-any
private getJson(url: string): Promise<any> { // tslint:disable-line:no-any
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers })
.then((response: SPHttpClientResponse): Promise<any> => { // tslint:disable-line:no-any
if (response.ok) { return response.json(); }
return response.text().then((text: string): Promise<any> => // tslint:disable-line:no-any
Promise.reject(new Error('SharePoint request failed (' + response.status + '): ' + text)));
});
}
private results(payload: any): any[] { // tslint:disable-line:no-any
const source: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
return source && Array.isArray(source.results) ? source.results : (Array.isArray(source) ? source : []);
}
private isGuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(this.cleanGuid(value)); }
private cleanGuid(value: string): string { return String(value || '').replace(/[{}]/g, '').toLowerCase(); }
}
@@ -0,0 +1,28 @@
export type EventsLayout = 'filmstrip' | 'compact';
export interface IEventsListOption {
id: string;
title: string;
serverRelativeUrl: string;
}
export interface IRecurringEvent {
key: string;
id: number;
title: string;
start: Date;
end: Date;
allDay: boolean;
location: string;
category: string;
description: string;
imageUrl: string;
detailsUrl: string;
recurring: boolean;
eventType: number;
}
export interface IEventDateRange {
start: Date;
end: Date;
}
@@ -0,0 +1,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "98155235-4169-4e88-ad12-24cc202223b6",
"alias": "RecurringEventsWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other", "de-de": "Weitere" },
"title": { "default": "Recurring Events", "de-de": "Ereignisse mit Serienterminen" },
"description": {
"default": "Displays events and expanded recurring event occurrences in a modern layout.",
"de-de": "Zeigt Ereignisse und expandierte Vorkommen von Serienterminen in einer modernen Darstellung an."
},
"officeFabricIconFontName": "Event",
"properties": {
"title": "Events",
"listId": "",
"category": "",
"rangeDays": 30,
"maxEvents": 10,
"layout": "filmstrip"
}
}]
}
@@ -0,0 +1,186 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
IPropertyPaneDropdownOption,
PropertyPaneDropdown,
PropertyPaneSlider,
PropertyPaneTextField
} from '@microsoft/sp-webpart-base';
import * as strings from 'RecurringEventsWebPartStrings';
import { calculateDateRange, normalizeMaxEvents, normalizeRangeDays } from './RecurringEventsCore';
import { RecurringEventsDataService } from './RecurringEventsDataService';
import { EventsLayout, IEventDateRange, IEventsListOption, IRecurringEvent } from './RecurringEventsTypes';
import RecurringEvents from './components/RecurringEvents';
import { IRecurringEventsProps } from './components/IRecurringEventsProps';
import styles = require('./components/RecurringEvents.module.scss');
export interface IRecurringEventsWebPartProps {
title: string;
listId: string;
category: string;
rangeDays: number;
maxEvents: number;
layout: EventsLayout;
}
export default class RecurringEventsWebPart extends BaseClientSideWebPart<IRecurringEventsWebPartProps> {
private service: RecurringEventsDataService;
private lists: IEventsListOption[] = [];
private categories: string[] = [];
private loadingLists: boolean = false;
private loadingCategories: boolean = false;
private requestVersion: number = 0;
private disposed: boolean = false;
protected onInit(): Promise<void> {
this.service = new RecurringEventsDataService(this.context);
return super.onInit().then((): Promise<void> => this.loadLists())
.then((): Promise<void> => this.properties.listId ? this.loadCategories(this.properties.listId) : Promise.resolve());
}
public render(): void {
const list: IEventsListOption = this.selectedList();
const version: number = ++this.requestVersion;
if (!list) { this.renderStatus(strings.ConfigureMessage, false); return; }
const range: IEventDateRange = calculateDateRange(normalizeRangeDays(this.properties.rangeDays), new Date());
this.renderStatus(strings.LoadingMessage, false);
this.service.getEvents(list, range, this.properties.category || '', normalizeMaxEvents(this.properties.maxEvents))
.then((events: IRecurringEvent[]): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (!events.length) { this.renderStatus(strings.EmptyMessage, false); return; }
const element: React.ReactElement<IRecurringEventsProps> = React.createElement(RecurringEvents, {
events: events,
title: this.properties.title || strings.DefaultTitle,
layout: this.properties.layout === 'compact' ? 'compact' : 'filmstrip',
locale: this.context.pageContext.cultureInfo.currentUICultureName || 'en-us',
allDayLabel: strings.AllDayLabel,
recurringLabel: strings.RecurringLabel
});
ReactDom.render(element, this.domElement);
}).catch((error: Error): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (window.console) { console.error('[RecurringEvents] ' + strings.LoadErrorMessage, error); }
this.renderStatus(strings.LoadErrorMessage, true);
});
}
protected onDispose(): void {
this.disposed = true;
this.requestVersion++;
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version { return Version.parse('1.0'); }
protected onPropertyPaneConfigurationStart(): void {
if (!this.lists.length && !this.loadingLists) { this.loadLists(); }
if (this.properties.listId && !this.categories.length && !this.loadingCategories) {
this.loadCategories(this.properties.listId);
}
}
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void { // tslint:disable-line:no-any
super.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
if (propertyPath === 'listId' && newValue !== oldValue) {
this.properties.category = '';
this.categories = [];
this.context.propertyPane.refresh();
this.loadCategories(String(newValue || '')).then((): void => this.render());
}
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const listOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectListOption }];
const categoryOptions: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.AllCategoriesOption }];
for (let i: number = 0; i < this.lists.length; i++) { listOptions.push({ key: this.lists[i].id, text: this.lists[i].title }); }
for (let j: number = 0; j < this.categories.length; j++) {
categoryOptions.push({ key: this.categories[j], text: this.categories[j] });
}
return {
pages: [{
header: { description: strings.PropertyPaneDescription },
groups: [{
groupName: strings.DataGroupName,
groupFields: [
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
PropertyPaneDropdown('listId', {
label: strings.ListFieldLabel,
options: listOptions,
disabled: this.loadingLists
}),
PropertyPaneDropdown('category', {
label: strings.CategoryFieldLabel,
options: categoryOptions,
disabled: !this.properties.listId || this.loadingCategories
}),
PropertyPaneDropdown('rangeDays', {
label: strings.RangeFieldLabel,
options: [
{ key: 7, text: strings.Range7Days },
{ key: 14, text: strings.Range14Days },
{ key: 30, text: strings.Range30Days },
{ key: 90, text: strings.Range90Days },
{ key: 365, text: strings.Range365Days }
]
})
]
}, {
groupName: strings.LayoutGroupName,
groupFields: [
PropertyPaneDropdown('layout', {
label: strings.LayoutFieldLabel,
options: [
{ key: 'filmstrip', text: strings.FilmstripLayout },
{ key: 'compact', text: strings.CompactLayout }
]
}),
PropertyPaneSlider('maxEvents', { label: strings.MaxEventsFieldLabel, min: 1, max: 50, step: 1 })
]
}]
}]
};
}
private loadLists(): Promise<void> {
this.loadingLists = true;
this.refreshPropertyPane();
return this.service.getLists().then((lists: IEventsListOption[]): void => {
this.lists = lists;
if (this.properties.listId && !this.selectedList()) { this.properties.listId = ''; this.properties.category = ''; }
}).catch((error: Error): void => {
this.lists = [];
if (window.console) { console.error('[RecurringEvents] Event lists could not be loaded.', error); }
}).then((): void => { this.loadingLists = false; this.refreshPropertyPane(); });
}
private loadCategories(listId: string): Promise<void> {
if (!listId) { this.categories = []; return Promise.resolve(); }
this.loadingCategories = true;
this.refreshPropertyPane();
return this.service.getCategories(listId).then((categories: string[]): void => {
this.categories = categories;
if (categories.indexOf(this.properties.category) < 0) { this.properties.category = ''; }
}).catch((): void => { this.categories = []; this.properties.category = ''; })
.then((): void => { this.loadingCategories = false; this.refreshPropertyPane(); });
}
private selectedList(): IEventsListOption {
for (let i: number = 0; i < this.lists.length; i++) {
if (this.lists[i].id === String(this.properties.listId || '').replace(/[{}]/g, '').toLowerCase()) { return this.lists[i]; }
}
return undefined as IEventsListOption;
}
private refreshPropertyPane(): void { if (this.context && this.context.propertyPane) { this.context.propertyPane.refresh(); } }
private renderStatus(message: string, error: boolean): void {
ReactDom.unmountComponentAtNode(this.domElement);
const status: HTMLDivElement = document.createElement('div');
status.className = error ? styles.error : styles.status;
status.setAttribute('role', error ? 'alert' : 'status');
status.textContent = message;
this.domElement.innerHTML = '';
this.domElement.appendChild(status);
}
}
@@ -0,0 +1,10 @@
import { EventsLayout, IRecurringEvent } from '../RecurringEventsTypes';
export interface IRecurringEventsProps {
events: IRecurringEvent[];
title: string;
layout: EventsLayout;
locale: string;
allDayLabel: string;
recurringLabel: string;
}
@@ -0,0 +1,82 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.recurringEvents {
color: "[theme:bodyText, default:#323130]";
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
}
.heading {
color: "[theme:bodyText, default:#323130]";
font-size: 24px;
font-weight: 300;
line-height: 32px;
margin: 0 0 20px;
}
.filmstrip {
display: flex;
gap: 16px;
list-style: none;
margin: 0;
overflow-x: auto;
padding: 0 0 8px;
scroll-snap-type: x proximity;
}
.card {
background: "[theme:white, default:#ffffff]";
border: 1px solid "[theme:neutralLight, default:#edebe9]";
box-sizing: border-box;
flex: 0 0 calc((100% - 32px) / 3);
min-width: 240px;
scroll-snap-align: start;
}
.cardLink, .compactLink {
color: "[theme:bodyText, default:#323130]";
display: flex;
text-decoration: none;
&:hover { color: "[theme:bodyText, default:#323130]"; }
&:focus { outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: -2px; }
}
.cardLink { display: block; height: 100%; }
.image, .imageFallback {
background-color: "[theme:themePrimary, default:#0078d4]";
background-position: center;
background-repeat: no-repeat;
background-size: cover;
display: block;
height: 150px;
position: relative;
}
.imageFallback { background: linear-gradient(135deg, "[theme:themePrimary, default:#0078d4]", "[theme:themeDark, default:#005a9e]"); }
.calendarIcon { color: #fff; font-size: 52px; left: 20px; opacity: .8; position: absolute; top: 16px; }
.cardBody { display: flex; min-height: 112px; padding: 16px; }
.cardContent, .compactContent { display: block; min-width: 0; }
.dateBadge {
color: "[theme:themePrimary, default:#0078d4]";
display: flex;
flex: 0 0 48px;
flex-direction: column;
margin-right: 14px;
text-align: center;
}
.month { font-size: 12px; font-weight: 600; line-height: 18px; text-transform: uppercase; }
.day { color: "[theme:bodyText, default:#323130]"; font-size: 28px; font-weight: 300; line-height: 32px; }
.title { display: block; font-size: 17px; font-weight: 600; line-height: 22px; overflow-wrap: break-word; }
.dateLine, .location { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 13px; line-height: 19px; margin-top: 4px; }
.recurring { color: "[theme:themePrimary, default:#0078d4]"; display: block; font-size: 12px; margin-top: 6px; }
.compact { list-style: none; margin: 0; padding: 0; }
.compactItem { border-bottom: 1px solid "[theme:neutralLight, default:#edebe9]"; }
.compactLink { align-items: center; min-height: 72px; padding: 12px 8px; }
.compactContent { flex: 1 1 auto; }
.recurringIcon { color: "[theme:themePrimary, default:#0078d4]"; flex: 0 0 30px; font-size: 20px; text-align: center; }
.status { color: "[theme:bodyText, default:#323130]"; padding: 16px; }
.error { color: "[theme:errorText, default:#a80000]"; padding: 16px; }
@media (max-width: 900px) { .card { flex-basis: calc((100% - 16px) / 2); } }
@media (max-width: 560px) { .card { flex-basis: 88%; } .heading { font-size: 21px; margin-bottom: 14px; } }
@@ -0,0 +1,87 @@
import * as React from 'react';
import { IRecurringEvent } from '../RecurringEventsTypes';
import styles = require('./RecurringEvents.module.scss');
import { IRecurringEventsProps } from './IRecurringEventsProps';
export default class RecurringEvents extends React.Component<IRecurringEventsProps, {}> {
public render(): React.ReactElement<IRecurringEventsProps> {
return (
<section className={styles.recurringEvents} aria-label={this.props.title}>
{this.props.title && <h2 className={styles.heading}>{this.props.title}</h2>}
{this.props.layout === 'compact' ? this.renderCompact() : this.renderFilmstrip()}
</section>
);
}
private renderFilmstrip(): JSX.Element {
return (
<ul className={styles.filmstrip} role='list'>
{this.props.events.map((event: IRecurringEvent): JSX.Element => (
<li className={styles.card} key={event.key}>
<a className={styles.cardLink} href={event.detailsUrl} aria-label={this.eventLabel(event)}>
<span className={event.imageUrl ? styles.image : styles.imageFallback}
style={event.imageUrl ? { backgroundImage: 'url("' + this.cssUrl(event.imageUrl) + '")' } : {}}>
{!event.imageUrl && <span className={styles.calendarIcon} aria-hidden='true'>&#9638;</span>}
</span>
<span className={styles.cardBody}>
{this.renderDateBadge(event)}
<span className={styles.cardContent}>
<span className={styles.title}>{event.title}</span>
<span className={styles.dateLine}>{this.formatDateLine(event)}</span>
{event.location && <span className={styles.location}>{event.location}</span>}
{event.recurring && <span className={styles.recurring}>{this.props.recurringLabel}</span>}
</span>
</span>
</a>
</li>
))}
</ul>
);
}
private renderCompact(): JSX.Element {
return (
<ul className={styles.compact} role='list'>
{this.props.events.map((event: IRecurringEvent): JSX.Element => (
<li className={styles.compactItem} key={event.key}>
<a className={styles.compactLink} href={event.detailsUrl} aria-label={this.eventLabel(event)}>
{this.renderDateBadge(event)}
<span className={styles.compactContent}>
<span className={styles.title}>{event.title}</span>
<span className={styles.dateLine}>{this.formatDateLine(event)}</span>
{event.location && <span className={styles.location}>{event.location}</span>}
</span>
{event.recurring && <span className={styles.recurringIcon} title={this.props.recurringLabel} aria-hidden='true'>&#8635;</span>}
</a>
</li>
))}
</ul>
);
}
private renderDateBadge(event: IRecurringEvent): JSX.Element {
return (
<span className={styles.dateBadge} aria-hidden='true'>
<span className={styles.month}>{event.start.toLocaleDateString(this.props.locale, { month: 'short' }).replace('.', '')}</span>
<span className={styles.day}>{event.start.getDate()}</span>
</span>
);
}
private formatDateLine(event: IRecurringEvent): string {
const date: string = event.start.toLocaleDateString(this.props.locale, { weekday: 'short', day: '2-digit', month: 'long' });
if (event.allDay) { return date + ' · ' + this.props.allDayLabel; }
const startTime: string = event.start.toLocaleTimeString(this.props.locale, { hour: '2-digit', minute: '2-digit' });
const endTime: string = event.end.toLocaleTimeString(this.props.locale, { hour: '2-digit', minute: '2-digit' });
return date + ' · ' + startTime + '–' + endTime;
}
private eventLabel(event: IRecurringEvent): string {
const parts: string[] = [event.title, this.formatDateLine(event)];
if (event.location) { parts.push(event.location); }
if (event.recurring) { parts.push(this.props.recurringLabel); }
return parts.join('. ');
}
private cssUrl(value: string): string { return String(value || '').replace(/["'()\\\r\n]/g, ''); }
}
+29
View File
@@ -0,0 +1,29 @@
define([], function() {
return {
PropertyPaneDescription: 'Zeigt normale Ereignisse und einzelne Vorkommen von Serienterminen an.',
DataGroupName: 'Ereignisquelle',
LayoutGroupName: 'Darstellung',
TitleFieldLabel: 'WebPart-Titel',
ListFieldLabel: 'Ereignisliste',
CategoryFieldLabel: 'Kategorie',
RangeFieldLabel: 'Zeitraum ab heute',
LayoutFieldLabel: 'Layout',
MaxEventsFieldLabel: 'Maximale Anzahl Ereignisse',
SelectListOption: 'Liste auswählen',
AllCategoriesOption: 'Alle Kategorien',
Range7Days: 'Nächste 7 Tage',
Range14Days: 'Nächste 14 Tage',
Range30Days: 'Nächste 30 Tage',
Range90Days: 'Nächste 3 Monate',
Range365Days: 'Nächstes Jahr',
FilmstripLayout: 'Filmstreifen',
CompactLayout: 'Kompakt',
ConfigureMessage: 'Bearbeiten Sie das WebPart und wählen Sie eine Ereignisliste aus.',
LoadingMessage: 'Ereignisse werden geladen…',
EmptyMessage: 'Im gewählten Zeitraum wurden keine Ereignisse gefunden.',
LoadErrorMessage: 'Die Ereignisse konnten nicht geladen werden.',
DefaultTitle: 'Ereignisse',
AllDayLabel: 'Ganztägig',
RecurringLabel: 'Serientermin'
};
});
+29
View File
@@ -0,0 +1,29 @@
define([], function() {
return {
PropertyPaneDescription: 'Displays regular events and individual occurrences of recurring events.',
DataGroupName: 'Event source',
LayoutGroupName: 'Layout',
TitleFieldLabel: 'Web part title',
ListFieldLabel: 'Events list',
CategoryFieldLabel: 'Category',
RangeFieldLabel: 'Date range from today',
LayoutFieldLabel: 'Layout',
MaxEventsFieldLabel: 'Maximum number of events',
SelectListOption: 'Select a list',
AllCategoriesOption: 'All categories',
Range7Days: 'Next 7 days',
Range14Days: 'Next 14 days',
Range30Days: 'Next 30 days',
Range90Days: 'Next 3 months',
Range365Days: 'Next year',
FilmstripLayout: 'Filmstrip',
CompactLayout: 'Compact',
ConfigureMessage: 'Edit the web part and select an Events list.',
LoadingMessage: 'Loading events…',
EmptyMessage: 'No events were found in the selected date range.',
LoadErrorMessage: 'The events could not be loaded.',
DefaultTitle: 'Events',
AllDayLabel: 'All day',
RecurringLabel: 'Recurring event'
};
});
+31
View File
@@ -0,0 +1,31 @@
declare interface IRecurringEventsWebPartStrings {
PropertyPaneDescription: string;
DataGroupName: string;
LayoutGroupName: string;
TitleFieldLabel: string;
ListFieldLabel: string;
CategoryFieldLabel: string;
RangeFieldLabel: string;
LayoutFieldLabel: string;
MaxEventsFieldLabel: string;
SelectListOption: string;
AllCategoriesOption: string;
Range7Days: string;
Range14Days: string;
Range30Days: string;
Range90Days: string;
Range365Days: string;
FilmstripLayout: string;
CompactLayout: string;
ConfigureMessage: string;
LoadingMessage: string;
EmptyMessage: string;
LoadErrorMessage: string;
DefaultTitle: string;
AllDayLabel: string;
RecurringLabel: string;
}
declare module 'RecurringEventsWebPartStrings' {
const strings: IRecurringEventsWebPartStrings;
export = strings;
}