Merge pull request #948 from hugoabernier/react-calendarfeed-patch-2

Upgraded React Calendar Feed to SPFx 1.9.1
This commit is contained in:
Laura Kokkarinen 2019-08-24 19:02:13 +03:00 committed by GitHub
commit 2a0bd9c18e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 4871 additions and 4706 deletions

View File

@ -2,10 +2,10 @@
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.7.1",
"version": "1.9.1",
"libraryName": "react-calendar-feed",
"libraryId": "25653136-fc83-4abe-b9d2-a4ac041959d5",
"packageManager": "npm",
"componentType": "webpart"
}
}
}

View File

@ -2,7 +2,15 @@
## Summary
This web part uses RSS event feeds, iCal feeds, or WordPress calendar feeds and renders events using a look and feel that is consistent with the SharePoint out-of-the-box Group calendar/events web part.
This web part uses event feeds from various sources and renders events using a look and feel that is consistent with the SharePoint out-of-the-box Group calendar/events web part.
It supports the following types of feeds:
- iCal
- WordPress
- RSS
- Exchange Public Calendar
- SharePoint
![The web part in action](./assets/react-calendar-feed-demo.gif)
@ -16,21 +24,21 @@ For more information about how this solution was built, including some design de
## Used SharePoint Framework Version
![SPFx v1.7.1](https://img.shields.io/badge/SPFx-1.7.1-green.svg)
![SPFx v1.9.1](https://img.shields.io/badge/SPFx-1.9.1-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
- [SharePoint Framework](https:/dev.office.com/sharepoint)
- [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
## Prerequisites
Before you can use this web part example, you will need one of the following:
* A publicly-accessible iCal feed (i.e.: .ics)
* A publicly-accessible RSS feed of events (e.g.: Google calendar)
* A WordPress WP-FullCalendar feed
* An Exchange Public Calendar
- A publicly-accessible iCal feed (i.e.: .ics)
- A publicly-accessible RSS feed of events (e.g.: Google calendar)
- A WordPress WP-FullCalendar feed
- An Exchange Public Calendar
It is important that all feeds do not require authentication. Also, make sure that your calendar includes upcoming events, as the web part will filter out evens that are earlier than today's date.
@ -49,7 +57,8 @@ Version|Date|Comments
1.0|May 15, 2018|Initial release
2.0|June 25, 2018|Converted to SPFx 1.5 and added Exchange Public Calendar support
3.0|November 9, 2018|Converted to SPFx 1.7; Added SharePoint Calendar feed
4.0|January 16. 2019|Converted to SPFx 1.7.1; Removed NPM libraries associated with issue #708.
4.0|January 16, 2019|Converted to SPFx 1.7.1; Removed NPM libraries associated with issue #708.
5.0|August 17, 2019|Converted to SPFx 1.9.1; Refreshed carousel code; Addresses #735, #909. Also added **Convert from UTC** option to handle feeds which do not provide time zone information.
## Disclaimer
@ -59,32 +68,32 @@ Version|Date|Comments
## Minimal Path to Awesome
* Clone this repository
* in the command line run:
* `npm install`
* `gulp serve`
* Insert the web part on a page
* When prompted to configure the web part, select **Configure** to launch the web part property pane.
* Select a feed type (RSS, iCal, WordPress, or Mock if using the debug solution)
* Provide the feed's URL. If using _Mock_, provide any valid URL (value will be ignored). If you wish to use a SharePoint calendar feed, provide the URL to the list (e.g.: https://yourtenant.sharepoint.com/sites/sitename/lists/eventlistname)
* Specify a date range (one week, two weeks, one month, one quarter, one year)
* Specify a maximum number of events to retrieve
* If necessary, specify to use a proxy. Use this option if you encounter issues where your feed provider does not accept your tenant URL as a CORS origin.
* If desired, specify how long (in minutes) you want to expire your users' local storage and refresh the events.
- Clone this repository
- in the command line run:
- `npm install`
- `gulp serve`
- Insert the web part on a page
- When prompted to configure the web part, select **Configure** to launch the web part property pane.
- Select a feed type (RSS, iCal, WordPress, or Mock if using the debug solution)
- Provide the feed's URL. If using _Mock_, provide any valid URL (value will be ignored). If you wish to use a SharePoint calendar feed, provide the URL to the list (e.g.: https://yourtenant.sharepoint.com/sites/sitename/lists/eventlistname)
- Specify a date range (one week, two weeks, one month, one quarter, one year)
- Specify a maximum number of events to retrieve
- If necessary, specify to use a proxy. Use this option if you encounter issues where your feed provider does not accept your tenant URL as a CORS origin.
- If desired, specify how long (in minutes) you want to expire your users' local storage and refresh the events.
## Features
This Web Part illustrates the following concepts on top of the SharePoint Framework:
* Rendering different views based on size
* Loading third-party CSS from a CDN
* Excluding mock data from production build
* Using @pnp/spfx-property-controls
* Using @pnp/spfx-controls-react
* Using localStorage to cache results locally
* Creating shared components and services
* Creating extensible services
* Using a proxy to resolve CORS issues
* Retrieving SharePoint events from a list with a filter
- Rendering different views based on size
- Loading third-party CSS from a CDN
- Excluding mock data from production build
- Using @pnp/spfx-property-controls
- Using @pnp/spfx-controls-react
- Using localStorage to cache results locally
- Creating shared components and services
- Creating extensible services
- Using a proxy to resolve CORS issues
- Retrieving SharePoint events from a list with a filter
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-calendar-feed" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
"name": "react-calendar-feed",
"version": "1.1.0",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
@ -11,10 +12,10 @@
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.7.1",
"@microsoft/sp-lodash-subset": "1.7.1",
"@microsoft/sp-office-ui-fabric-core": "1.7.1",
"@microsoft/sp-webpart-base": "1.7.1",
"@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-webpart-base": "1.9.1",
"@pnp/common": "^1.2.8",
"@pnp/logging": "^1.2.8",
"@pnp/odata": "^1.2.8",
@ -22,29 +23,31 @@
"@pnp/spfx-controls-react": "^1.11.0",
"@pnp/spfx-property-controls": "^1.13.1",
"@types/es6-promise": "0.0.33",
"@types/react": "16.4.2",
"@types/react-dom": "16.0.5",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"feedparser": "^2.2.9",
"ical.js": "^1.3.0",
"ics-js": "^0.10.2",
"react": "16.3.2",
"react-dom": "16.3.2",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5",
"react-slick": "^0.23.2",
"rss-parser": "^3.6.2",
"slick-carousel": "^1.8.1"
},
"resolutions": {
"@types/react": "16.4.2"
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.7.1",
"@microsoft/sp-tslint-rules": "1.7.1",
"@microsoft/sp-module-interfaces": "1.7.1",
"@microsoft/sp-webpart-workbench": "1.7.1",
"gulp": "~3.9.1",
"@microsoft/rush-stack-compiler-2.9": "0.7.16",
"@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-tslint-rules": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2"
"ajv": "~5.2.2",
"gulp": "~3.9.1"
}
}

View File

@ -1,66 +0,0 @@
$white: '[theme:white,default:#ffffff]';
$black: '[theme:black,default:#000000]';
.carouselContainer.filmStrip {
margin: 0 -10px;
}
.carouselContainer.filmStrip:global(.slick-slide) {
box-sizing: border-box;
padding: 0 10px;
}
.indexButtonContainer {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
}
.indexButton {
font-size: 17px;
font-weight: 300;
height: 40px;
padding: 0;
border: 0;
background: 0 0;
cursor: pointer;
color: $white;
width: 40px;
min-width: 20px;
margin-left: 0;
line-height: 40px;
box-sizing: content-box;
background-color: $black;
opacity: .6;
position: absolute;
top: 50%;
transform: translateY(-50%);
transition: all .3s;
}
.carouselContainer .sliderButtons {
opacity: 0;
}
.carouselContainer:hover .sliderButtons {
opacity: 1;
}
.sliderButtons:hover {
color: $white;
}
.indexButton:global(.ms-Button-flexContainer):hover:global(.ms-Icon),
.indexButton:global(.ms-Icon:hover),
.indexButton:hover:global(.ms-Icon) {
color: $white;
}
.leftPositioned {
left: 0;
}
.rightPositioned {
right: 0;
}

View File

@ -1,88 +0,0 @@
import { css } from "@uifabric/utilities/lib/css";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import * as React from "react";
import Slider from "react-slick";
import { ICarouselContainerProps, ICarouselContainerState } from ".";
import styles from "./CarouselContainer.module.scss";
/**
* Carousel container
* Presents the child compoments as a slick slide
*/
export class CarouselContainer extends React.Component<
ICarouselContainerProps,
ICarouselContainerState
> {
// the slick slider used in normal views
private _slider: Slider;
/**
* Renders a slick switch, a slide for each child, and next/previous arrows
*/
public render(): React.ReactElement<ICarouselContainerProps> {
// slick seems to have an issue with having "infinite" mode set to true and having less items than the number of slides per page
// set infinite to true only if there are more than 3 children
var isInfinite: boolean = React.Children.count(this.props.children) > 3;
var settings: any = {
accessibility: true,
arrows: false,
autoplaySpeed: 5000,
dots: true,
infinite: isInfinite,
slidesToShow: 4,
slidesToScroll: 4,
speed: 500,
centerPadding: "50px",
pauseOnHover: true,
variableWidth: false,
useCSS: true,
rows: 1,
respondTo: "slider",
responsive: [
{
breakpoint: 2560,
settings: {
slidesToShow: 3,
slidesToScroll: 3
}
},
{
breakpoint: 801,
settings: {
slidesToShow: 2,
slidesToScroll: 2
}
}
// there is no 1 slide option, as it converts to narrow view
]
};
return (
<div className={css(styles.carouselContainer, styles.filmStrip)}>
<Slider ref={c => (this._slider = c)} {...settings}>
{this.props.children}
</Slider>
<div
className={css(styles.indexButtonContainer, styles.sliderButtons)}
style={{ left: "10px" }}
onClick={() => this._slider.slickPrev()}
>
<IconButton
className={css(styles.indexButton, styles.leftPositioned)}
iconProps={{ iconName: "ChevronLeft" }}
/>
</div>
<div
className={css(styles.indexButtonContainer, styles.sliderButtons)}
style={{ right: "10px" }}
onClick={() => this._slider.slickNext()}
>
<IconButton
className={css(styles.indexButton, styles.rightPositioned)}
iconProps={{ iconName: "ChevronRight" }}
/>
</div>
</div>
);
}
}

View File

@ -1,3 +0,0 @@
export interface ICarouselContainerProps { }
export interface ICarouselContainerState { }

View File

@ -1,2 +0,0 @@
export * from "./CarouselContainer";
export * from "./CarouselContainer.types";

View File

@ -1,10 +1,9 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
$neutralPrimary: '[theme:neutralPrimary,default:#333333]';
@import '~office-ui-fabric-react/dist/sass/References.scss';
.box {
font-weight: 400;
border: 1px solid;
color: $neutralPrimary;
color: $ms-color-neutralPrimary;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;

View File

@ -11,7 +11,9 @@ export class DateBox extends React.Component<IDateBoxProps, IDateBoxState> {
public render(): React.ReactElement<IDateBoxProps> {
// convert start and end date into moments so that we can manipulate them
const startMoment: moment.Moment = moment(this.props.startDate);
const endMoment: moment.Moment = moment(this.props.endDate);
// event actually ends one second before the end date
const endMoment: moment.Moment = moment(this.props.endDate).add(-1, "s");
// check if both dates are on the same day
const isSameDay: boolean = startMoment.isSame(endMoment, "day");

View File

@ -12,4 +12,4 @@ export interface IDateBoxState {
export enum DateBoxSize {
Small,
Medium
}
}

View File

@ -1,9 +1,4 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
$neutralLight: '[theme:neutralLight,default:#eaeaea]';
$neutralSecondary: '[theme:neutralSecondary,default:#666666]';
$white: '[theme:white,default:#ffffff]';
$neutralPrimary: '[theme:neutralPrimary,default:#333333]';
$neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
@import '~office-ui-fabric-react/dist/sass/References.scss';
.cardWrapper {
border: 1px solid;
@ -24,11 +19,11 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
}
.cardWrapper:focus {
border-color: $neutralSecondary;
border-color: $ms-color-neutralSecondary;
}
.cardWrapper .dateBox {
border-color: $neutralTertiaryAlt;
border-color: $ms-color-neutralTertiaryAlt;
}
.normalCard .dateBoxContainer {
@ -81,11 +76,11 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
.category,
.location {
color: $neutralPrimary;
color: $ms-color-neutralPrimary;
}
.datetime {
color: $neutralPrimary;
color: $ms-color-neutralPrimary;
}
.title {
@ -96,7 +91,7 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
.addToMyCalendar,
.title {
font-weight: 400;
color: $neutralPrimary;
color: $ms-color-neutralPrimary;
}
.addToMyCalendar {
@ -116,7 +111,7 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
width: 100%;
align-items: center;
-webkit-font-smoothing: antialiased;
background-color: $white;
background-color: $ms-color-white;
border: 1px solid #eaeaea;
-webkit-box-sizing: border-box;
box-sizing: border-box;
@ -143,11 +138,11 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
}
.rootIsActionable:hover {
border-color: $neutralLight;
border-color: $ms-color-neutralLight;
}
.dateBox {
border-color: $neutralTertiaryAlt;
border-color: $ms-color-neutralTertiaryAlt;
}
.normalCard .title {
@ -167,7 +162,7 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
.category,
.location {
color:$neutralSecondary;
color:$ms-color-neutralSecondary;
}
.category,
@ -182,7 +177,7 @@ $neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
height: 18px;
}
:global(.slick-slide) .cardWrapper {
padding-left:8px;
padding-right:8px;
}
// :global(.slick-slide) .cardWrapper {
// padding-left:8px;
// padding-right:8px;
// }

View File

@ -1,3 +1,5 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.Paging {
width: 100%;
min-width: 240px;

View File

@ -0,0 +1,89 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
:export {
centerPadding: 50px;
}
.filmstripLayout {
position: relative;
&.filmStrip {
margin: 0 -10px;
:global(.slick-slide) {
box-sizing: border-box;
padding: 0 10px;
}
}
.sliderButtons {
opacity: 0;
}
.sliderButtonRight {
right: 10px;
}
.sliderButtonLeft {
left: 10px;
}
&:hover .sliderButtons {
opacity: 1;
&:hover {
color: $ms-color-white;
}
}
}
.indexButtonContainer {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
}
.indexButton {
font-size: 17px;
font-weight: 300;
height: 40px;
padding: 0;
border: 0;
background: 0 0;
cursor: pointer;
color: $ms-color-white;
width: 40px;
min-width: 20px;
margin-left: 0;
line-height: 40px;
box-sizing: content-box;
background-color: $ms-color-black;
opacity: 0.6;
position: absolute;
top: 50%;
transform: translateY(-50%);
transition: all 0.3s;
&:hover {
color: $ms-color-white;
}
&:active {
outline: -webkit-focus-ring-color auto 1px;
}
}
.indexButton:global(.ms-Button-flexContainer):hover:global(.ms-Icon),
.indexButton:global(.ms-Icon:hover),
.indexButton:hover:global(.ms-Icon) {
color: $ms-color-white;
}
.leftPositioned {
left: 0;
}
.rightPositioned {
right: 0;
}

View File

@ -0,0 +1,114 @@
import { css } from '@uifabric/utilities/lib/css';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import * as React from 'react';
// import * as slick from 'slick-carousel';
import Slider from 'react-slick';
import { IFilmstripLayoutProps, IFilmstripLayoutState } from "./FilmstripLayout.types";
import { SPComponentLoader } from '@microsoft/sp-loader';
import styles from "./FilmstripLayout.module.scss";
/**
* Filmstrip layout
* Presents the child compoments as a slick slide
*/
export class FilmstripLayout extends React.Component<
IFilmstripLayoutProps,
IFilmstripLayoutState
> {
// the slick slider used in normal views
private _slider: Slider;
/**
*
*/
constructor(props: IFilmstripLayoutProps) {
super(props);
SPComponentLoader.loadCss('https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.6.0/slick.min.css');
SPComponentLoader.loadCss('https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.6.0/slick-theme.min.css');
}
/**
* Renders a slick switch, a slide for each child, and next/previous arrows
*/
public render(): React.ReactElement<IFilmstripLayoutProps> {
// slick seems to have an issue with having "infinite" mode set to true and having less items than the number of slides per page
// set infinite to true only if there are more than 3 children
var isInfinite: boolean = React.Children.count(this.props.children) > 3;
var settings: any = {
accessibility: true,
arrows: false,
autoplaySpeed: 5000,
dots: true,
infinite: isInfinite,
slidesToShow: 4,
slidesToScroll: 4,
speed: 500,
centerPadding: styles.centerPadding,
pauseOnHover: true,
variableWidth: false,
useCSS: true,
rows: 1,
respondTo: "slider",
responsive: [
{
breakpoint: 499,
settings: {
slidesToShow: 1,
slidesToScroll: 1
}
},
{
breakpoint: 731,
settings: {
slidesToShow: 2,
slidesToScroll: 2
}
},
{
breakpoint: 963,
settings: {
slidesToShow: 3,
slidesToScroll: 3
}
},
{
breakpoint: 1028,
settings: {
slidesToShow: 4,
slidesToScroll: 4
}
}
]
};
return (
<div>
<div className={css(styles.filmstripLayout, styles.filmStrip)} aria-label={this.props.ariaLabel}>
<Slider ref={c => (this._slider = c)} {...settings}>
{this.props.children}
</Slider>
<div
className={css(styles.indexButtonContainer, styles.sliderButtons, styles.sliderButtonLeft)}
onClick={() => this._slider.slickPrev()}
>
<IconButton
className={css(styles.indexButton, styles.leftPositioned)}
iconProps={{ iconName: "ChevronLeft" }}
/>
</div>
<div
className={css(styles.indexButtonContainer, styles.sliderButtons, styles.sliderButtonRight)}
onClick={() => this._slider.slickNext()}
>
<IconButton
className={css(styles.indexButton, styles.rightPositioned)}
iconProps={{ iconName: "ChevronRight" }}
/>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,5 @@
export interface IFilmstripLayoutProps {
ariaLabel?: string;
}
export interface IFilmstripLayoutState { }

View File

@ -0,0 +1,2 @@
export * from "./FilmstripLayout";
export * from "./FilmstripLayout.types";

View File

@ -12,93 +12,131 @@ import { ICalendarService } from "./ICalendarService";
* choose to do so. We won't judge.
*/
export abstract class BaseCalendarService implements ICalendarService {
public Context: IWebPartContext;
public FeedUrl: string;
public EventRange: CalendarEventRange;
public UseCORS: boolean;
public CacheDuration: number;
public Name: string;
public Context: IWebPartContext;
public FeedUrl: string;
public EventRange: CalendarEventRange;
public UseCORS: boolean;
public CacheDuration: number;
public Name: string;
public MaxTotal: number;
public ConvertFromUTC: boolean;
public getEvents: () => Promise<ICalendarEvent[]>;
/**
* Solves an issue where some providers (I'm looking at you, WordPress) returns all-day events
* as starting from midight on the first day, and ending at midnight on the second day, making events
* appear as lasting 2 days when they should last only 1 day
* @param event The event that needs to be fixed
*/
protected fixAllDayEvents(events: ICalendarEvent[]): ICalendarEvent[] {
events.forEach((event: ICalendarEvent) => {
if (event.allDay) {
const startMoment: moment.Moment = moment(event.start);
const endMoment: moment.Moment = moment(event.end).add(-1, "minute");
public getEvents: () => Promise<ICalendarEvent[]>;
/**
* Solves an issue where some providers (I'm looking at you, WordPress) returns all-day events
* as starting from midight on the first day, and ending at midnight on the second day, making events
* appear as lasting 2 days when they should last only 1 day
* @param event The event that needs to be fixed
*/
protected fixAllDayEvents(events: ICalendarEvent[]): ICalendarEvent[] {
events.forEach((event: ICalendarEvent) => {
if (event.allDay) {
const startMoment: moment.Moment = moment(event.start);
const endMoment: moment.Moment = moment(event.end).add(-1, "minute");
if (startMoment.isSame(endMoment, "day")) {
event.end = event.start;
}
}
return event;
});
return events;
if (startMoment.isSame(endMoment, "day")) {
event.end = event.start;
}
}
return event;
});
return events;
}
/**
* Not every provider allows the feed to be filtered. Use this method to filter events after
* the provider has retrieved them so that we can be consistent regardless of the provider
* @param events The list of events to filter
*/
protected filterEventRange(events: ICalendarEvent[]): ICalendarEvent[] {
const {
Start,
End } = this.EventRange;
// not all providers are good at (or capable of) filtering by events, let's just filter out events that fit outside the range
events = events.filter(e => e.start >= Start && e.end <= End);
// sort events by date in case we need to truncate
events.sort((leftSide: ICalendarEvent, rightSide: ICalendarEvent): number => {
if (leftSide.start < rightSide.start) {
return -1;
}
if (leftSide.start > rightSide.start) {
return 1;
}
return 0;
});
return events;
}
/**
* This is a cheesy approach to inject start and end dates from a feed url.
*/
protected replaceTokens(feedUrl: string, dateRange: CalendarEventRange): string {
const startMoment: moment.Moment = moment(dateRange.Start);
const startDate: string = startMoment.format("YYYY-MM-DD");
const endDate: string = startMoment.format("YYYY-MM-DD");
return feedUrl.replace("{s}", startDate)
.replace("{e}", endDate);
}
/**
* Retrieves the response using a CORS proxy or directly, depending on the settings
* @param feedUrl The URL where to retrieve the events
*/
protected fetchResponse(feedUrl: string): Promise<HttpClientResponse> {
// would love to use a different approach to workaround CORS issues
const requestUrl: string = this.getCORSUrl(feedUrl);
return this.Context.httpClient.fetch(requestUrl,
HttpClient.configurations.v1, {});
}
/**
* Returns a URL or a CORS-formatted URL
* @param feedUrl The URL for the feed
*/
protected getCORSUrl(feedUrl: string): string {
// would love to use a different approach to workaround CORS issues
return this.UseCORS ?
`https://cors-anywhere.herokuapp.com/${feedUrl}` :
feedUrl;
}
/**
* Retrives the response and returns a JSON object
* @param feedUrl The URL where to retrieve the events
*/
protected async fetchResponseAsJson(feedUrl: string): Promise<any> {
try {
const response = await this.fetchResponse(feedUrl);
return await response.json();
}
catch (error) {
throw error;
}
}
/**
* Converts a value to a date, possibly as a UTC date
* @param dateValue The date value to convert
*/
protected convertToDate(dateValue: any): Date {
let returnDate: Date = new Date(dateValue);
if (this.ConvertFromUTC) {
returnDate = new Date(returnDate.getUTCFullYear(),
returnDate.getUTCMonth(),
returnDate.getUTCDate(),
returnDate.getUTCHours(),
returnDate.getUTCMinutes(),
returnDate.getUTCSeconds()
);
}
/**
* Not every provider allows the feed to be filtered. Use this method to filter events after
* the provider has retrieved them so that we can be consistent regardless of the provider
* @param events The list of events to filter
*/
protected filterEventRange(events: ICalendarEvent[]): ICalendarEvent[] {
const { Start,
End } = this.EventRange;
// not all providers are good at (or capable of) filtering by events, let's just filter out events that fit outside the range
events = events.filter(e => e.start >= Start && e.end <= End);
return events;
}
/**
* This is a cheesy approach to inject start and end dates from a feed url.
*/
protected replaceTokens(feedUrl: string, dateRange: CalendarEventRange): string {
const startMoment: moment.Moment = moment(dateRange.Start);
const startDate: string = startMoment.format("YYYY-MM-DD");
const endDate: string = startMoment.format("YYYY-MM-DD");
return feedUrl.replace("{s}", startDate)
.replace("{e}", endDate);
}
/**
* Retrieves the response using a CORS proxy or directly, depending on the settings
* @param feedUrl The URL where to retrieve the events
*/
protected fetchResponse(feedUrl: string): Promise<HttpClientResponse> {
// would love to use a different approach to workaround CORS issues
const requestUrl: string = this.getCORSUrl(feedUrl);
return this.Context.httpClient.fetch(requestUrl,
HttpClient.configurations.v1, {});
}
/**
* Returns a URL or a CORS-formatted URL
* @param feedUrl The URL for the feed
*/
protected getCORSUrl(feedUrl: string): string {
// would love to use a different approach to workaround CORS issues
return this.UseCORS ?
`https://cors-anywhere.herokuapp.com/${feedUrl}` :
feedUrl;
}
/**
* Retrives the response and returns a JSON object
* @param feedUrl The URL where to retrieve the events
*/
protected fetchResponseAsJson(feedUrl: string): Promise<any> {
return this.fetchResponse(feedUrl)
.then((response: HttpClientResponse) => response.json(), (error: any) => {
throw error;
});
}
return returnDate;
}
}

View File

@ -7,4 +7,4 @@ export interface ICalendarEvent {
category: string|undefined;
description: string|undefined;
location: string|undefined;
}
}

View File

@ -7,6 +7,8 @@ export interface ICalendarService {
EventRange: CalendarEventRange;
UseCORS: boolean;
CacheDuration: number;
MaxTotal: number;
ConvertFromUTC: boolean;
Name: string;
getEvents: () => Promise<ICalendarEvent[]>;
}

View File

@ -9,149 +9,149 @@ import { ICalendarEvent } from "../ICalendarEvent";
import { ICalendarService } from "../ICalendarService";
const sampleEvents: ICalendarEvent[] = [
{
"title": "This event will be tomorrow",
"start": moment().add(1, "d").toDate(),
"end": moment().add(1, "d").toDate(),
"url": "https://www.contoso.com/news-events/events/1/",
"allDay": true,
"category": "Meeting",
"location": "Barrie, ON",
"description": "This is a description"
},
{
"title": "This event will be in one week",
"start": moment().add(1, "w").toDate(),
"end": moment().add(1, "w").add(1, "h").toDate(),
"url": "https://www.contoso.com/news-events/events/2/",
"allDay": false,
"category": "Meeting",
"location": undefined,
"description": undefined
},
{
"title": "This event will last two days",
"start": moment().add(1, "w").toDate(),
"end": moment().add(1, "w").add(2, "d").toDate(),
"url": "https://www.contoso.com/news-events/events/2/",
"allDay": true,
"category": "Meeting",
"location": undefined,
"description": undefined
},
{
"title": "This event will be in two weeks",
"start": moment().add(2, "w").toDate(),
"end": moment().add(2, "w").toDate(),
"url": "https://www.contoso.com/news-events/events/3/",
"allDay": true,
"category": "Meeting",
"location": undefined,
"description": undefined
},
{
"title": "This event will be in one month",
"start": moment().add(1, "M").toDate(),
"end": moment().add(1, "M").add(2, "d").toDate(),
"url": "https://www.contoso.com/news-events/events/4/",
"allDay": true,
"category": "Meeting",
"location": undefined,
"description": undefined
},
{
"title": "This event will be in two months",
"start": moment().add(2, "M").toDate(),
"end": moment().add(2, "M").toDate(),
"url": "https://www.contoso.com/news-events/events/5/",
"allDay": true,
"category": "Meeting",
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 1 quarter",
"start": moment().add(1, "Q").toDate(),
"end": moment().add(1, "Q").toDate(),
"url": "https://www.contoso.com/news-events/events/6/",
"allDay": true,
"category": undefined,
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 4 months",
"start": moment().add(4, "M").toDate(),
"end": moment().add(4, "M").toDate(),
"url": "https://www.contoso.com/news-events/events/7/",
"allDay": true,
"category": undefined,
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 5 months",
"start": moment().add(5, "M").toDate(),
"end": moment().add(5, "M").toDate(),
"url": "https://www.contoso.com/news-events/events/8/",
"allDay": true,
"category": undefined,
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 6 months",
"start": moment().add(6, "M").toDate(),
"end": moment().add(6, "M").toDate(),
"url": "https://www.contoso.com/news-events/events/9/",
"allDay": true,
"category": undefined,
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 9 months",
"start": moment().add(9, "M").toDate(),
"end": moment().add(9, "M").toDate(),
"url": "https://www.contoso.com/news-events/events/10/",
"allDay": true,
"category": undefined,
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 1 year",
"start": moment().add(1, "y").toDate(),
"end": moment().add(1, "y").toDate(),
"url": "https://www.contoso.com/news-events/events/11/",
"allDay": true,
"category": "Partayyyy!",
"location": undefined,
"description": undefined
},
{
"title": "This event will be in 18 months",
"start": moment().add(18, "M").toDate(),
"end": moment().add(18, "M").toDate(),
"url": "https://www.contoso.com/news-events/events/12/",
"allDay": true,
"category": "Meeting",
"location": undefined,
"description": undefined
}
{
title: "This event will be tomorrow",
start: moment().add(1, "d").toDate(),
end: moment().add(1, "d").toDate(),
url: "https://www.contoso.com/news-events/events/1/",
allDay: true,
category: "Meeting",
location: "Barrie, ON",
description: "This is a description"
},
{
title: "This event will be in one week",
start: moment().add(1, "w").toDate(),
end: moment().add(1, "w").add(1, "h").toDate(),
url: "https://www.contoso.com/news-events/events/2/",
allDay: false,
category: "Meeting",
location: undefined,
description: undefined
},
{
title: "This event will last two days",
start: moment().add(1, "w").toDate(),
end: moment().add(1, "w").add(2, "d").toDate(),
url: "https://www.contoso.com/news-events/events/2/",
allDay: true,
category: "Meeting",
location: undefined,
description: undefined
},
{
title: "This event will be in two weeks",
start: moment().add(2, "w").toDate(),
end: moment().add(2, "w").toDate(),
url: "https://www.contoso.com/news-events/events/3/",
allDay: true,
category: "Meeting",
location: undefined,
description: undefined
},
{
title: "This event will be in one month",
start: moment().add(1, "M").toDate(),
end: moment().add(1, "M").add(2, "d").toDate(),
url: "https://www.contoso.com/news-events/events/4/",
allDay: true,
category: "Meeting",
location: undefined,
description: undefined
},
{
title: "This event will be in two months",
start: moment().add(2, "M").toDate(),
end: moment().add(2, "M").toDate(),
url: "https://www.contoso.com/news-events/events/5/",
allDay: true,
category: "Meeting",
location: undefined,
description: undefined
},
{
title: "This event will be in 1 quarter",
start: moment().add(1, "Q").toDate(),
end: moment().add(1, "Q").toDate(),
url: "https://www.contoso.com/news-events/events/6/",
allDay: true,
category: undefined,
location: undefined,
description: undefined
},
{
title: "This event will be in 4 months",
start: moment().add(4, "M").toDate(),
end: moment().add(4, "M").toDate(),
url: "https://www.contoso.com/news-events/events/7/",
allDay: true,
category: undefined,
location: undefined,
description: undefined
},
{
title: "This event will be in 5 months",
start: moment().add(5, "M").toDate(),
end: moment().add(5, "M").toDate(),
url: "https://www.contoso.com/news-events/events/8/",
allDay: true,
category: undefined,
location: undefined,
description: undefined
},
{
title: "This event will be in 6 months",
start: moment().add(6, "M").toDate(),
end: moment().add(6, "M").toDate(),
url: "https://www.contoso.com/news-events/events/9/",
allDay: true,
category: undefined,
location: undefined,
description: undefined
},
{
title: "This event will be in 9 months",
start: moment().add(9, "M").toDate(),
end: moment().add(9, "M").toDate(),
url: "https://www.contoso.com/news-events/events/10/",
allDay: true,
category: undefined,
location: undefined,
description: undefined
},
{
title: "This event will be in 1 year",
start: moment().add(1, "y").toDate(),
end: moment().add(1, "y").toDate(),
url: "https://www.contoso.com/news-events/events/11/",
allDay: true,
category: "Partayyyy!",
location: undefined,
description: undefined
},
{
title: "This event will be in 18 months",
start: moment().add(18, "M").toDate(),
end: moment().add(18, "M").toDate(),
url: "https://www.contoso.com/news-events/events/12/",
allDay: true,
category: "Meeting",
location: undefined,
description: undefined
}
];
export class MockCalendarService extends BaseCalendarService implements ICalendarService {
constructor() {
super();
this.Name = "Mock";
}
constructor() {
super();
this.Name = "Mock";
}
public getEvents = (): Promise<ICalendarEvent[]> => {
return new Promise<ICalendarEvent[]>((resolve: any) => {
setTimeout(() => {
resolve(this.filterEventRange(sampleEvents));
}, 1000);
});
}
public getEvents = (): Promise<ICalendarEvent[]> => {
return new Promise<ICalendarEvent[]>((resolve: any) => {
setTimeout(() => {
resolve(this.filterEventRange(sampleEvents));
}, 1000);
});
}
}

View File

@ -24,8 +24,8 @@ export class RSSCalendarService extends BaseCalendarService implements ICalendar
return parser.parseURL(parameterizedFeedUrl).then(feed => {
let events: ICalendarEvent[] = feed.items.map(item => {
let pubDate: Date = new Date(item.isoDate);
return {
let pubDate: Date = this.convertToDate(item.isoDate);
const eventItem: ICalendarEvent = {
title: item.title,
start: pubDate,
end: pubDate,
@ -35,6 +35,7 @@ export class RSSCalendarService extends BaseCalendarService implements ICalendar
location: undefined, // no equivalent in RSS
category: item.categories && item.categories.length > 0 && item.categories[0]
};
return eventItem;
});
return events;
});

View File

@ -15,14 +15,14 @@ export class SharePointCalendarService extends BaseCalendarService
this.Name = "SharePoint";
}
public getEvents = (): Promise<ICalendarEvent[]> => {
public getEvents = async (): Promise<ICalendarEvent[]> => {
const parameterizedFeedUrl: string = this.replaceTokens(
this.FeedUrl,
this.EventRange
);
// Get the URL
let webUrl = this.FeedUrl.toLowerCase();
let webUrl = parameterizedFeedUrl.toLowerCase();
// Break the URL into parts
let urlParts = webUrl.split("/");
@ -41,42 +41,36 @@ export class SharePointCalendarService extends BaseCalendarService
let web = new Web(siteUrl);
// Get the web
return web.get().then(() => {
// Build a filter so that we don't retrieve every single thing unless necesssary
let dateFilter:string = "EventDate ge datetime'"+this.EventRange.Start.toISOString()+"' and EndDate lt datetime'"+this.EventRange.End.toISOString()+"'";
// When we receive the web, get the list
return web
.getList(listUrl)
await web.get();
// Build a filter so that we don't retrieve every single thing unless necesssary
let dateFilter: string = "EventDate ge datetime'" + this.EventRange.Start.toISOString() + "' and EndDate lt datetime'" + this.EventRange.End.toISOString() + "'";
try {
const items = await web.getList(listUrl)
.items.select("Id,Title,Description,EventDate,EndDate,fAllDayEvent,Category,Location")
.orderBy('EventDate', true)
.filter(dateFilter)
.getAll()
.then((items: any[]) => {
// Once we get the list, convert to calendar events
let events: ICalendarEvent[] = items.map((item: any) => {
let eventUrl:string = combine(webUrl, "DispForm.aspx?ID="+item.Id);
return {
title: item.Title,
start: item.EventDate,
end: item.EndDate,
url: eventUrl,
allDay: item.fAllDayEvent,
category: item.Category,
description: item.Description,
location: item.Location
};
});
// Return the calendar items
return events;
})
.catch((error: any) => {
console.log(
"Exception caught by catch in SharePoint provider",
error
);
throw error;
});
});
.get();
// Once we get the list, convert to calendar events
let events: ICalendarEvent[] = items.map((item: any) => {
let eventUrl: string = combine(webUrl, "DispForm.aspx?ID=" + item.Id);
const eventItem: ICalendarEvent = {
title: item.Title,
start: item.EventDate,
end: item.EndDate,
url: eventUrl,
allDay: item.fAllDayEvent,
category: item.Category,
description: item.Description,
location: item.Location
};
return eventItem;
});
// Return the calendar items
return events;
}
catch (error) {
console.log("Exception caught by catch in SharePoint provider", error);
throw error;
}
}
}

View File

@ -12,31 +12,32 @@ export class WordPressFullCalendarService extends BaseCalendarService implements
this.Name = "WordPress";
}
public getEvents = (): Promise<ICalendarEvent[]> => {
public getEvents = async (): Promise<ICalendarEvent[]> => {
const parameterizedFeedUrl: string = this.replaceTokens(this.FeedUrl, this.EventRange);
return this.fetchResponseAsJson(parameterizedFeedUrl)
.then((data: IWordPressFullCalendarEventResponse[]): ICalendarEvent[] => {
let events: ICalendarEvent[] = data.map((e: IWordPressFullCalendarEventResponse) => {
return {
title: e.title,
start: new Date(e.start),
end: new Date(e.end),
url: e.url,
post_id: e.post_id,
event_id: e.event_id,
allDay: e.allDay,
description: undefined, // none found in WordPress
category: undefined, // none found in WordPress
location: undefined // none found in WordPress
};
});
return this.filterEventRange(this.fixAllDayEvents(events));
}).catch((error: any) => {
console.log("Exception caught by catch in WordPress provider", error);
throw error;
});
try {
const data = await this.fetchResponseAsJson(parameterizedFeedUrl);
let events: ICalendarEvent[] = data.map((e: IWordPressFullCalendarEventResponse) => {
const startDate: Date = this.convertToDate(e.start);
const endDate: Date = this.convertToDate(e.end);
const eventItem: ICalendarEvent = {
title: e.title,
start: startDate,
end: endDate,
url: e.url,
allDay: e.allDay,
description: undefined,
category: undefined,
location: undefined // none found in WordPress
};
return eventItem;
});
return this.filterEventRange(this.fixAllDayEvents(events));
}
catch (error) {
console.log("Exception caught by catch in WordPress provider", error);
throw error;
}
}
}

View File

@ -1,7 +1,6 @@
/**
* ExtensionService
*/
import { HttpClientResponse } from "@microsoft/sp-http";
import * as ICAL from "ical.js";
import { ICalendarService } from "..";
import { BaseCalendarService } from "../BaseCalendarService";
@ -9,42 +8,44 @@ import { ICalendarEvent } from "../ICalendarEvent";
// tslint:disable-next-line:class-name
export class iCalCalendarService extends BaseCalendarService implements ICalendarService {
constructor() {
super();
this.Name = "iCal";
constructor() {
super();
this.Name = "iCal";
}
public getEvents = async (): Promise<ICalendarEvent[]> => {
const parameterizedFeedUrl: string = this.replaceTokens(this.FeedUrl, this.EventRange);
try {
const response = await this.fetchResponse(parameterizedFeedUrl);
const data = await response.text();
const jsonified: any = ICAL.parse(data);
const comp: any = new ICAL.Component(jsonified);
const veventList: any[] = comp.getAllSubcomponents("vevent");
let events: ICalendarEvent[] = veventList.map((vevent: any) => {
const event: ICAL.Event = new ICAL.Event(vevent);
let startDate = this.convertToDate(event.startDate);
let endDate = this.convertToDate(event.endDate);
const eventItem: ICalendarEvent = {
title: event.summary,
start: startDate,
end: endDate,
url: event.url,
allDay: event.startDate.icaltype === "date",
category: event.category,
description: event.description,
location: event.location
};
return eventItem;
});
return this.filterEventRange(events);
}
public getEvents = (): Promise<ICalendarEvent[]> => {
const parameterizedFeedUrl: string = this.replaceTokens(this.FeedUrl, this.EventRange);
return this.fetchResponse(parameterizedFeedUrl)
.then((response: HttpClientResponse) => response.text())
.then((data: string) => {
let jsonified: any = ICAL.parse(data);
var comp: any = new ICAL.Component(jsonified);
var veventList: any[] = comp.getAllSubcomponents("vevent");
return veventList;
})
.then((data: any[]) => {
let events: ICalendarEvent[] = data.map((vevent: any) => {
var event: ICAL.Event = new ICAL.Event(vevent);
return {
title: event.summary,
start: new Date(event.startDate),
end: new Date(event.endDate),
url: event.url,
allDay: event.allDay,
category: event.category,
description: event.description,
location: event.location
};
});
return this.filterEventRange(events);
}).catch((error: any) => {
console.log("Exception caught by catch in iCal provider", error);
throw error;
});
catch (error) {
console.log("Exception caught by catch in iCal provider", error);
throw error;
}
}
}

View File

@ -6,7 +6,7 @@
"version": "*",
"manifestVersion": 2,
"supportedHosts": ["SharePointWebPart"],
"requiresCustomScript": false,
"preconfiguredEntries": [{
@ -26,7 +26,9 @@
"dateRange": 4,
"maxEvents": 4,
"useCORS": false,
"cacheDuration": 15
"cacheDuration": 15,
"maxTotal": 10,
"convertFromUTC": false
}
}]
}

View File

@ -1,13 +1,14 @@
import * as React from "react";
import * as ReactDom from "react-dom";
// SharePoint imports
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
IPropertyPaneDropdownOption,
PropertyPaneDropdown
} from "@microsoft/sp-webpart-base";
PropertyPaneDropdown,
PropertyPaneToggle,
PropertyPaneLabel
} from "@microsoft/sp-property-pane";
// Needed for data versions
import { Version } from '@microsoft/sp-core-library';
@ -58,6 +59,8 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
let {
cacheDuration,
dateRange,
maxTotal,
convertFromUTC: convertFromUTC
} = this.properties;
// make sure to set a default date range if it isn't defined
@ -71,6 +74,14 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
cacheDuration = 15;
}
if (maxTotal === undefined) {
maxTotal = 0;
}
if (convertFromUTC === undefined) {
convertFromUTC = false;
}
resolve(undefined);
});
}
@ -127,7 +138,9 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
maxEvents,
useCORS,
cacheDuration,
feedType
feedType,
maxTotal,
convertFromUTC
} = this.properties;
const isMock: boolean = feedType === CalendarServiceProviderType.Mock;
@ -179,14 +192,19 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
groupName: strings.AdvancedGroupName,
isCollapsed: true,
groupFields: [
// how many items are we diplaying in a page
PropertyFieldNumber("maxEvents", {
key: "maxEventsFieldId",
label: strings.MaxEventsFieldLabel,
description: strings.MaxEventsFieldDescription,
value: maxEvents,
minValue: 0,
disabled: false
PropertyPaneLabel('convertFromUTC', {
text: strings.ConvertFromUTCFieldDescription
}),
// Convert from UTC toggle
PropertyPaneToggle("convertFromUTC", {
key: "convertFromUTCFieldId",
label: strings.ConvertFromUTCLabel,
onText: strings.ConvertFromUTCOptionYes,
offText: strings.ConvertFromUTCOptionNo,
checked: convertFromUTC,
}),
PropertyPaneLabel('useCORS', {
text: strings.UseCorsFieldDescription
}),
// use CORS toggle
PropertyFieldToggleWithCallout("useCORS", {
@ -194,8 +212,8 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
calloutTrigger: CalloutTriggers.Hover,
key: "useCORSFieldId",
label: strings.UseCORSFieldLabel,
calloutWidth: 200,
calloutContent: React.createElement("div", {}, isMock ? strings.UseCORSFieldCalloutDisabled : strings.UseCORSFieldCallout),
//calloutWidth: 200,
calloutContent: React.createElement("p", {}, isMock ? strings.UseCORSFieldCalloutDisabled : strings.UseCORSFieldCallout),
onText: strings.CORSOn,
offText: strings.CORSOff,
checked: useCORS
@ -212,6 +230,23 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
step: 15,
showValue: true,
value: cacheDuration
}),
// how many items are we diplaying in a page
PropertyFieldNumber("maxEvents", {
key: "maxEventsFieldId",
label: strings.MaxEventsFieldLabel,
description: strings.MaxEventsFieldDescription,
value: maxEvents,
minValue: 0,
disabled: false
}),
PropertyFieldNumber("maxTotal", {
key: "maxTotalFieldId",
label: strings.MaxTotalFieldLabel,
description: strings.MaxTotalFieldDescription,
value: maxTotal,
minValue: 0,
disabled: false
})
],
}
@ -293,7 +328,9 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
const {
feedUrl,
useCORS,
cacheDuration
cacheDuration,
convertFromUTC,
maxTotal
} = this.properties;
// get the first provider matching the type selected
@ -314,6 +351,8 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
provider.UseCORS = useCORS;
provider.CacheDuration = cacheDuration;
provider.EventRange = new CalendarEventRange(this.properties.dateRange);
provider.ConvertFromUTC = convertFromUTC;
provider.MaxTotal = maxTotal;
return provider;
}
}

View File

@ -4,11 +4,48 @@ import { DateRange, CalendarServiceProviderType } from "../../shared/services/Ca
* Web part properties stored in web part configuration
*/
export interface ICalendarFeedSummaryWebPartProps {
title: string; // title of the web part
feedUrl: string; // the URL where to get the feed from
feedType: CalendarServiceProviderType; // the type of feed provider
maxEvents: number; // maximum number of events
dateRange: DateRange; // date range to retrieve events
useCORS: boolean; // use CORS proxy when retrieving events
cacheDuration: number; // how long to cache events for
}
/**
* The title of the web part
*/
title: string;
/**
* The URL where to get the feed from
*/
feedUrl: string;
/**
* The type of feed provider
*/
feedType: CalendarServiceProviderType;
/**
* maximum number of events per page
*/
maxEvents: number;
/**
* Maximum total number of events to load
*/
maxTotal: number;
/**
* Date range to retrieve events
*/
dateRange: DateRange;
/**
* use CORS proxy when retrieving events
*/
useCORS: boolean;
/**
* how long to cache events for
*/
cacheDuration: number;
/**
* Indicates the dates received from feeds do not specify a timezone
*/
convertFromUTC: boolean;
}

View File

@ -1,17 +1,10 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
$themeWhite: '[theme:white,default:white]';
$neutralLight: '[theme:neutralLight,default:#eaeaea]';
$neutralLighter: '[theme:neutralLighter,default:#f4f4f4]';
$neutralSecondary: '[theme:neutralSecondary,default:#666666]';
$black: '[theme:black,default:#000000]';
$white: '[theme:white,default:#ffffff]';
$themeLight: '[theme:themeLight,default:#c7e0f4]';
$themeSecondary: '[theme:themeSecondary,default:#2b88d8]';
$neutralPrimary: '[theme:neutralPrimary,default:#333333]';
$neutralTertiaryAlt: "[theme:neutralTertiaryAlt, default: #c8c8c8]";
$error: "[theme:error, default: #a80000]";
@import '~office-ui-fabric-react/dist/sass/References.scss';
.calendarFeedSummary {
// this is a trick I use to create classes that are empty
// just define an attribute which uses 'inherit'
// it has no impact to styles, but won't complain
// about the empty CSS class
color: inherit;
}
@ -63,16 +56,16 @@ $error: "[theme:error, default: #a80000]";
overflow: hidden;
display: inline-block;
margin: 10px 0 15px;
color: #666666;
color: $ms-color-neutralSecondary;
}
.errorMessage {
overflow: hidden;
display: inline-block;
margin: 10px 0 15px;
color: #666666;
color: $ms-color-neutralSecondary;
}
.errorMessage .moreDetails {
color:$error;
}
color:$ms-color-error;
}

View File

@ -6,12 +6,12 @@ import * as strings from "CalendarFeedSummaryWebPartStrings";
import * as moment from "moment";
import { FocusZone, FocusZoneDirection, List, Spinner, css } from "office-ui-fabric-react";
import * as React from "react";
import { CarouselContainer } from "../../../shared/components/CarouselContainer";
import { EventCard } from "../../../shared/components/EventCard";
import { Paging } from "../../../shared/components/Paging";
import { CalendarServiceProviderType, ICalendarEvent, ICalendarService } from "../../../shared/services/CalendarService";
import styles from "./CalendarFeedSummary.module.scss";
import { ICalendarFeedSummaryProps, ICalendarFeedSummaryState, IFeedCache } from "./CalendarFeedSummary.types";
import { FilmstripLayout } from "../../../shared/components/filmstripLayout/index";
// the key used when caching events
const CacheKey: string = "calendarFeedSummary";
@ -28,10 +28,6 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
error: undefined,
currentPage: 1
};
// needed for the slick slider in normal mode
SPComponentLoader.loadCss("https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.8.1/slick.min.css");
SPComponentLoader.loadCss("https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.8.1/slick-theme.min.css");
}
/**
@ -73,7 +69,9 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
prevProvider.FeedUrl !== currProvider.FeedUrl ||
prevProvider.Name !== currProvider.Name ||
prevProvider.EventRange.DateRange !== currProvider.EventRange.DateRange ||
prevProvider.UseCORS !== currProvider.UseCORS;
prevProvider.UseCORS !== currProvider.UseCORS ||
prevProvider.MaxTotal !== currProvider.MaxTotal ||
prevProvider.ConvertFromUTC !== currProvider.ConvertFromUTC;
if (settingsHaveChanged) {
// only load from cache if the providers haven't changed, otherwise reload.
@ -289,7 +287,9 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
return (<div>
<div>
<div role="application">
<CarouselContainer >
<FilmstripLayout
ariaLabel={strings.FilmStripAriaLabel}
>
{events.map((event: ICalendarEvent, index: number) => {
return (<EventCard
key={`eventCard${index}`}
@ -297,7 +297,7 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
event={event}
isNarrow={false} />);
})}
</CarouselContainer>
</FilmstripLayout>
</div>
</div>
</div>);
@ -313,7 +313,7 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
/**
* Load events from the cache or, if expired, load from the event provider
*/
private _loadEvents(useCacheIfPossible: boolean): Promise<void> {
private async _loadEvents(useCacheIfPossible: boolean): Promise<void> {
// before we do anything with the data provider, let's make sure that we don't have stuff stored in the cache
// load from cache if: 1) we said to use cache, and b) if we have something in cache
@ -340,7 +340,11 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
isLoading: true
});
return dataProvider.getEvents().then((events: ICalendarEvent[]) => {
try {
let events = await dataProvider.getEvents();
if (dataProvider.MaxTotal > 0) {
events = events.slice(0, dataProvider.MaxTotal);
}
// don't cache in the case of errors
this.setState({
isLoading: false,
@ -348,14 +352,15 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
events: events
});
return;
}).catch((error: any) => {
}
catch (error) {
console.log("Exception returned by getEvents", error.message);
this.setState({
isLoading: false,
error: error.message,
events: []
});
});
}
}
}
}

View File

@ -1,58 +1,66 @@
define([], function() {
return {
"PropertyPaneDescription": "Select the type of feed you wish to connect to and the feed URL.",
"AllItemsUrlFieldLabel": "View all events URL",
"FeedUrlFieldLabel": "Feed URL",
"FeedTypeFieldLabel": "Feed type",
"PlaceholderTitle": "Configure event feed",
"PlaceholderDescription": "To display a summary of events, you need to select a feed type and configure the event feed URL.",
"ConfigureButton": "Configure",
"FeedTypeOptionGoogle": "Google Calendar",
"FeedTypeOptioniCal": "iCal",
"FeedTypeOptionRSS": "RSS Calendar",
"FeedTypeOptionWordPress": "WordPress WP_FullCalendar",
"FeedUrlCallout": "If your feed supports date range parameters, use {s} and {e} for start and end dates, and we'll replace them with date values.",
"MaxEventsFieldLabel": "Maximum number of events per page",
"MaxEventsFieldDescription": "Indicates the number of events to show per page when displaying a narrow list. Use 0 for no maximum",
"DateRangeFieldLabel": "Date range",
"DateRangeOptionUpcoming": "Next year",
"DateRangeOptionWeek": "Next week",
"DateRangeOptionTwoWeeks": "Next two weeks",
"DateRangeOptionMonth": "Next month",
"DateRangeOptionQuarter": "Next quarter",
"UseCORSFieldLabel": "Use proxy",
"UseCORSFieldCallout": "Enable this option if you get a CORS message",
"UseCORSFieldCalloutDisabled": "This option is disabled when using the Mock provider",
"CORSOn": "On",
"CORSOff": "Off",
"AdvancedGroupName": "Advanced",
"FocusZoneAriaLabelReadMode": "Events list. Use up and down arrow keys to move between events. Press enter to obtain details on a selected event.",
"FocusZoneAriaLabelEditMode": "Events list. Use up and down arrow keys to move between events.",
"EventCardWrapperArialLabel": "Event {0}. Start on {1}.",
"Loading": "Please wait...",
"NoEventsMessage": "There aren't any upcoming events.",
"CacheDurationFieldLabel": "Cache duration (minutes)",
"CacheDurationFieldCallout": "Use 0 if you do not want to cache events. Maximum value is 1 day (14,400 minutes).",
"FeedUrlValidationNoUrl": "Provide a URL",
"FeedUrlValidationInvalidFormat": "URL is not a valid format. Please use a URL that starts with http:// or https://. ",
"ErrorMessage": "Oops, something went wrong! We can't display your events at the moment. Please try again later.",
"NextButtonLabel": "Next",
"PrevButtonLabel": "Previous",
"NextButtonAriaLabel": "Go to the Next page",
"PrevButtonAriaLabel": "Go to the Previous page",
"ErrorNotFound":"The feed URL you specified cannot be found. Make sure that you have the right URL and try again.",
"ErrorMixedContent": "Failed to fetch the feed URL you specified. Try using an https:// URL, or enable the \"Use proxy\" option",
"ErrorFailedToFetch": "Failed to fetch the feed URL you specified. This may be due to an invalid URL.",
"ErrorFailedToFetchNoProxy": "Failed to fetch the feed URL you specified. This may be due to an invalid URL or a CORS issue. Verify the URL, or enable the \"Use proxy\" option.",
"ErrorRssNoResult":"The feed you specified does not appear to be a RSS feed",
"ErrorRssNoRoot": "The RSS feed you specified appear to be invalid: it does not have a root",
"ErrorRssNoChannel": "The RSS feed you specified appear to be invalid: it does not have a channel",
"ErrorInvalidiCalFeed": "The URL you provided does not appear to be an iCal feed. Are you sure you selected the right feed type?",
"ErrorInvalidWordPressFeed": "The URL you provided does not appear to be a WordPress feed. Are you sure you selected the right feed type?",
"AddToCalendarAriaLabel": "Press enter to download the calendar file to your device.",
"AddToCalendarButtonLabel": "Add to my calendar",
"AllDayDateFormat": "dddd, MMMM Do YYYY",
"LocalizedTimeFormat": "llll",
"FeedSettingsGroupName": "Calendar feed"
UseCorsFieldDescription: "If you get a message saying \"Failed to fetch the feed URL you specified.\", try enabling the \"Use proxy\" option.",
ConvertFromUTCFieldDescription: "If your feed returns Universal Time Coordinated (UTC) events and your events appear at the wrong time zone, try enabling \"Convert from UTC\".",
ConvertFromUTCOptionNo: "Do not convert",
ConvertFromUTCOptionYes: "Convert",
ConvertFromUTCLabel: "Convert from UTC",
MaxTotalFieldDescription: "Indicates the total number of events to load. Use 0 for no maximum.",
MaxTotalFieldLabel: "Maximum number of events",
FilmStripAriaLabel: "Events list. Use left and right arrow keys to move between events. Press enter to go to the selected event.",
PropertyPaneDescription: "Select the type of feed you wish to connect to and the feed URL.",
AllItemsUrlFieldLabel: "View all events URL",
FeedUrlFieldLabel: "Feed URL",
FeedTypeFieldLabel: "Feed type",
PlaceholderTitle: "Configure event feed",
PlaceholderDescription: "To display a summary of events, you need to select a feed type and configure the event feed URL.",
ConfigureButton: "Configure",
FeedTypeOptionGoogle: "Google Calendar",
FeedTypeOptioniCal: "iCal",
FeedTypeOptionRSS: "RSS Calendar",
FeedTypeOptionWordPress: "WordPress WP_FullCalendar",
FeedUrlCallout: "If your feed supports date range parameters, use {s} and {e} for start and end dates, and we'll replace them with date values.",
MaxEventsFieldLabel: "Narrow page length",
MaxEventsFieldDescription: "Indicates the number of events to show per page when displaying a narrow list. Use 0 for no maximum",
DateRangeFieldLabel: "Date range",
DateRangeOptionUpcoming: "Next year",
DateRangeOptionWeek: "Next week",
DateRangeOptionTwoWeeks: "Next two weeks",
DateRangeOptionMonth: "Next month",
DateRangeOptionQuarter: "Next quarter",
UseCORSFieldLabel: "Use proxy",
UseCORSFieldCallout: "Enable this option if you get a CORS message",
UseCORSFieldCalloutDisabled: "This option is disabled when using the Mock provider",
CORSOn: "On",
CORSOff: "Off",
AdvancedGroupName: "Advanced",
FocusZoneAriaLabelReadMode: "Events list. Use up and down arrow keys to move between events. Press enter to obtain details on a selected event.",
FocusZoneAriaLabelEditMode: "Events list. Use up and down arrow keys to move between events.",
EventCardWrapperArialLabel: "Event {0}. Start on {1}.",
Loading: "Please wait...",
NoEventsMessage: "There aren't any upcoming events.",
CacheDurationFieldLabel: "Cache duration (minutes)",
CacheDurationFieldCallout: "Use 0 if you do not want to cache events. Maximum value is 1 day (14,400 minutes).",
FeedUrlValidationNoUrl: "Provide a URL",
FeedUrlValidationInvalidFormat: "URL is not a valid format. Please use a URL that starts with http:// or https://. ",
ErrorMessage: "Oops, something went wrong! We can't display your events at the moment. Please try again later.",
NextButtonLabel: "Next",
PrevButtonLabel: "Previous",
NextButtonAriaLabel: "Go to the Next page",
PrevButtonAriaLabel: "Go to the Previous page",
ErrorNotFound:"The feed URL you specified cannot be found. Make sure that you have the right URL and try again.",
ErrorMixedContent: "Failed to fetch the feed URL you specified. Try using an https:// URL, or enable the \"Use proxy\" option",
ErrorFailedToFetch: "Failed to fetch the feed URL you specified. This may be due to an invalid URL.",
ErrorFailedToFetchNoProxy: "Failed to fetch the feed URL you specified. This may be due to an invalid URL or a CORS issue. Verify the URL, or enable the \"Use proxy\" option.",
ErrorRssNoResult:"The feed you specified does not appear to be a RSS feed",
ErrorRssNoRoot: "The RSS feed you specified appear to be invalid: it does not have a root",
ErrorRssNoChannel: "The RSS feed you specified appear to be invalid: it does not have a channel",
ErrorInvalidiCalFeed: "The URL you provided does not appear to be an iCal feed. Are you sure you selected the right feed type?",
ErrorInvalidWordPressFeed: "The URL you provided does not appear to be a WordPress feed. Are you sure you selected the right feed type?",
AddToCalendarAriaLabel: "Press enter to download the calendar file to your device.",
AddToCalendarButtonLabel: "Add to my calendar",
AllDayDateFormat: "dddd, MMMM Do YYYY",
LocalizedTimeFormat: "llll",
FeedSettingsGroupName: "Calendar feed"
}
});

View File

@ -1,4 +1,12 @@
declare interface ICalendarFeedSummaryWebPartStrings {
UseCorsFieldDescription: string;
ConvertFromUTCFieldDescription: string;
ConvertFromUTCOptionNo: string;
ConvertFromUTCOptionYes: string;
ConvertFromUTCLabel: string;
MaxTotalFieldDescription: string;
MaxTotalFieldLabel: string;
FilmStripAriaLabel: string;
PropertyPaneDescription: string;
BasicGroupName: string;
AllItemsUrlFieldLabel: string;

View File

@ -1,5 +1,8 @@
{
"compilerOptions": {
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
@ -30,5 +33,6 @@
"exclude": [
"node_modules",
"lib"
]
],
"extends": "./node_modules/@microsoft/rush-stack-compiler-2.9/includes/tsconfig-web.json"
}