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": { "@microsoft/generator-sharepoint": {
"isCreatingSolution": true, "isCreatingSolution": true,
"environment": "spo", "environment": "spo",
"version": "1.7.1", "version": "1.9.1",
"libraryName": "react-calendar-feed", "libraryName": "react-calendar-feed",
"libraryId": "25653136-fc83-4abe-b9d2-a4ac041959d5", "libraryId": "25653136-fc83-4abe-b9d2-a4ac041959d5",
"packageManager": "npm", "packageManager": "npm",
"componentType": "webpart" "componentType": "webpart"
} }
} }

View File

@ -2,7 +2,15 @@
## Summary ## 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) ![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 ## 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 ## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint) - [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment) - [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
## Prerequisites ## Prerequisites
Before you can use this web part example, you will need one of the following: 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 iCal feed (i.e.: .ics)
* A publicly-accessible RSS feed of events (e.g.: Google calendar) - A publicly-accessible RSS feed of events (e.g.: Google calendar)
* A WordPress WP-FullCalendar feed - A WordPress WP-FullCalendar feed
* An Exchange Public Calendar - 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. 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 1.0|May 15, 2018|Initial release
2.0|June 25, 2018|Converted to SPFx 1.5 and added Exchange Public Calendar support 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 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 ## Disclaimer
@ -59,32 +68,32 @@ Version|Date|Comments
## Minimal Path to Awesome ## Minimal Path to Awesome
* Clone this repository - Clone this repository
* in the command line run: - in the command line run:
* `npm install` - `npm install`
* `gulp serve` - `gulp serve`
* Insert the web part on a page - Insert the web part on a page
* When prompted to configure the web part, select **Configure** to launch the web part property pane. - 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) - 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) - 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 date range (one week, two weeks, one month, one quarter, one year)
* Specify a maximum number of events to retrieve - 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 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. - If desired, specify how long (in minutes) you want to expire your users' local storage and refresh the events.
## Features ## Features
This Web Part illustrates the following concepts on top of the SharePoint Framework: This Web Part illustrates the following concepts on top of the SharePoint Framework:
* Rendering different views based on size - Rendering different views based on size
* Loading third-party CSS from a CDN - Loading third-party CSS from a CDN
* Excluding mock data from production build - Excluding mock data from production build
* Using @pnp/spfx-property-controls - Using @pnp/spfx-property-controls
* Using @pnp/spfx-controls-react - Using @pnp/spfx-controls-react
* Using localStorage to cache results locally - Using localStorage to cache results locally
* Creating shared components and services - Creating shared components and services
* Creating extensible services - Creating extensible services
* Using a proxy to resolve CORS issues - Using a proxy to resolve CORS issues
* Retrieving SharePoint events from a list with a filter - Retrieving SharePoint events from a list with a filter
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-calendar-feed" /> <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", "name": "react-calendar-feed",
"version": "1.1.0", "version": "1.1.0",
"private": true, "private": true,
"main": "lib/index.js",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
}, },
@ -11,10 +12,10 @@
"test": "gulp test" "test": "gulp test"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-core-library": "1.7.1", "@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-lodash-subset": "1.7.1", "@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.7.1", "@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-webpart-base": "1.7.1", "@microsoft/sp-webpart-base": "1.9.1",
"@pnp/common": "^1.2.8", "@pnp/common": "^1.2.8",
"@pnp/logging": "^1.2.8", "@pnp/logging": "^1.2.8",
"@pnp/odata": "^1.2.8", "@pnp/odata": "^1.2.8",
@ -22,29 +23,31 @@
"@pnp/spfx-controls-react": "^1.11.0", "@pnp/spfx-controls-react": "^1.11.0",
"@pnp/spfx-property-controls": "^1.13.1", "@pnp/spfx-property-controls": "^1.13.1",
"@types/es6-promise": "0.0.33", "@types/es6-promise": "0.0.33",
"@types/react": "16.4.2", "@types/react": "16.8.8",
"@types/react-dom": "16.0.5", "@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1", "@types/webpack-env": "1.13.1",
"feedparser": "^2.2.9", "feedparser": "^2.2.9",
"ical.js": "^1.3.0", "ical.js": "^1.3.0",
"ics-js": "^0.10.2", "ics-js": "^0.10.2",
"react": "16.3.2", "office-ui-fabric-react": "6.189.2",
"react-dom": "16.3.2", "react": "16.8.5",
"react-dom": "16.8.5",
"react-slick": "^0.23.2", "react-slick": "^0.23.2",
"rss-parser": "^3.6.2", "rss-parser": "^3.6.2",
"slick-carousel": "^1.8.1" "slick-carousel": "^1.8.1"
}, },
"resolutions": { "resolutions": {
"@types/react": "16.4.2" "@types/react": "16.8.8"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/sp-build-web": "1.7.1", "@microsoft/rush-stack-compiler-2.9": "0.7.16",
"@microsoft/sp-tslint-rules": "1.7.1", "@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-module-interfaces": "1.7.1", "@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.7.1", "@microsoft/sp-tslint-rules": "1.9.1",
"gulp": "~3.9.1", "@microsoft/sp-webpart-workbench": "1.9.1",
"@types/chai": "3.4.34", "@types/chai": "3.4.34",
"@types/mocha": "2.2.38", "@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'; @import '~office-ui-fabric-react/dist/sass/References.scss';
$neutralPrimary: '[theme:neutralPrimary,default:#333333]';
.box { .box {
font-weight: 400; font-weight: 400;
border: 1px solid; border: 1px solid;
color: $neutralPrimary; color: $ms-color-neutralPrimary;
display: -ms-flexbox; display: -ms-flexbox;
display: flex; display: flex;
-ms-flex-direction: column; -ms-flex-direction: column;

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.Paging { .Paging {
width: 100%; width: 100%;
min-width: 240px; 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. * choose to do so. We won't judge.
*/ */
export abstract class BaseCalendarService implements ICalendarService { export abstract class BaseCalendarService implements ICalendarService {
public Context: IWebPartContext; public Context: IWebPartContext;
public FeedUrl: string; public FeedUrl: string;
public EventRange: CalendarEventRange; public EventRange: CalendarEventRange;
public UseCORS: boolean; public UseCORS: boolean;
public CacheDuration: number; public CacheDuration: number;
public Name: string; public Name: string;
public MaxTotal: number;
public ConvertFromUTC: boolean;
public getEvents: () => Promise<ICalendarEvent[]>; public getEvents: () => Promise<ICalendarEvent[]>;
/** /**
* Solves an issue where some providers (I'm looking at you, WordPress) returns all-day events * 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 * 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 * appear as lasting 2 days when they should last only 1 day
* @param event The event that needs to be fixed * @param event The event that needs to be fixed
*/ */
protected fixAllDayEvents(events: ICalendarEvent[]): ICalendarEvent[] { protected fixAllDayEvents(events: ICalendarEvent[]): ICalendarEvent[] {
events.forEach((event: ICalendarEvent) => { events.forEach((event: ICalendarEvent) => {
if (event.allDay) { if (event.allDay) {
const startMoment: moment.Moment = moment(event.start); const startMoment: moment.Moment = moment(event.start);
const endMoment: moment.Moment = moment(event.end).add(-1, "minute"); const endMoment: moment.Moment = moment(event.end).add(-1, "minute");
if (startMoment.isSame(endMoment, "day")) { if (startMoment.isSame(endMoment, "day")) {
event.end = event.start; event.end = event.start;
} }
} }
return event; return event;
}); });
return events; 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()
);
} }
/** return returnDate;
* 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;
});
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
/** /**
* ExtensionService * ExtensionService
*/ */
import { HttpClientResponse } from "@microsoft/sp-http";
import * as ICAL from "ical.js"; import * as ICAL from "ical.js";
import { ICalendarService } from ".."; import { ICalendarService } from "..";
import { BaseCalendarService } from "../BaseCalendarService"; import { BaseCalendarService } from "../BaseCalendarService";
@ -9,42 +8,44 @@ import { ICalendarEvent } from "../ICalendarEvent";
// tslint:disable-next-line:class-name // tslint:disable-next-line:class-name
export class iCalCalendarService extends BaseCalendarService implements ICalendarService { export class iCalCalendarService extends BaseCalendarService implements ICalendarService {
constructor() { constructor() {
super(); super();
this.Name = "iCal"; 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);
} }
catch (error) {
public getEvents = (): Promise<ICalendarEvent[]> => { console.log("Exception caught by catch in iCal provider", error);
const parameterizedFeedUrl: string = this.replaceTokens(this.FeedUrl, this.EventRange); throw error;
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;
});
} }
}
} }

View File

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

View File

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

View File

@ -4,11 +4,48 @@ import { DateRange, CalendarServiceProviderType } from "../../shared/services/Ca
* Web part properties stored in web part configuration * Web part properties stored in web part configuration
*/ */
export interface ICalendarFeedSummaryWebPartProps { export interface ICalendarFeedSummaryWebPartProps {
title: string; // title of the web part /**
feedUrl: string; // the URL where to get the feed from * The title of the web part
feedType: CalendarServiceProviderType; // the type of feed provider */
maxEvents: number; // maximum number of events title: string;
dateRange: DateRange; // date range to retrieve events
useCORS: boolean; // use CORS proxy when retrieving events /**
cacheDuration: number; // how long to cache events for * 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'; @import '~office-ui-fabric-react/dist/sass/References.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]";
.calendarFeedSummary { .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; color: inherit;
} }
@ -63,16 +56,16 @@ $error: "[theme:error, default: #a80000]";
overflow: hidden; overflow: hidden;
display: inline-block; display: inline-block;
margin: 10px 0 15px; margin: 10px 0 15px;
color: #666666; color: $ms-color-neutralSecondary;
} }
.errorMessage { .errorMessage {
overflow: hidden; overflow: hidden;
display: inline-block; display: inline-block;
margin: 10px 0 15px; margin: 10px 0 15px;
color: #666666; color: $ms-color-neutralSecondary;
} }
.errorMessage .moreDetails { .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 * as moment from "moment";
import { FocusZone, FocusZoneDirection, List, Spinner, css } from "office-ui-fabric-react"; import { FocusZone, FocusZoneDirection, List, Spinner, css } from "office-ui-fabric-react";
import * as React from "react"; import * as React from "react";
import { CarouselContainer } from "../../../shared/components/CarouselContainer";
import { EventCard } from "../../../shared/components/EventCard"; import { EventCard } from "../../../shared/components/EventCard";
import { Paging } from "../../../shared/components/Paging"; import { Paging } from "../../../shared/components/Paging";
import { CalendarServiceProviderType, ICalendarEvent, ICalendarService } from "../../../shared/services/CalendarService"; import { CalendarServiceProviderType, ICalendarEvent, ICalendarService } from "../../../shared/services/CalendarService";
import styles from "./CalendarFeedSummary.module.scss"; import styles from "./CalendarFeedSummary.module.scss";
import { ICalendarFeedSummaryProps, ICalendarFeedSummaryState, IFeedCache } from "./CalendarFeedSummary.types"; import { ICalendarFeedSummaryProps, ICalendarFeedSummaryState, IFeedCache } from "./CalendarFeedSummary.types";
import { FilmstripLayout } from "../../../shared/components/filmstripLayout/index";
// the key used when caching events // the key used when caching events
const CacheKey: string = "calendarFeedSummary"; const CacheKey: string = "calendarFeedSummary";
@ -28,10 +28,6 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
error: undefined, error: undefined,
currentPage: 1 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.FeedUrl !== currProvider.FeedUrl ||
prevProvider.Name !== currProvider.Name || prevProvider.Name !== currProvider.Name ||
prevProvider.EventRange.DateRange !== currProvider.EventRange.DateRange || prevProvider.EventRange.DateRange !== currProvider.EventRange.DateRange ||
prevProvider.UseCORS !== currProvider.UseCORS; prevProvider.UseCORS !== currProvider.UseCORS ||
prevProvider.MaxTotal !== currProvider.MaxTotal ||
prevProvider.ConvertFromUTC !== currProvider.ConvertFromUTC;
if (settingsHaveChanged) { if (settingsHaveChanged) {
// only load from cache if the providers haven't changed, otherwise reload. // 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> return (<div>
<div> <div>
<div role="application"> <div role="application">
<CarouselContainer > <FilmstripLayout
ariaLabel={strings.FilmStripAriaLabel}
>
{events.map((event: ICalendarEvent, index: number) => { {events.map((event: ICalendarEvent, index: number) => {
return (<EventCard return (<EventCard
key={`eventCard${index}`} key={`eventCard${index}`}
@ -297,7 +297,7 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
event={event} event={event}
isNarrow={false} />); isNarrow={false} />);
})} })}
</CarouselContainer> </FilmstripLayout>
</div> </div>
</div> </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 * 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 // 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 // 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 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 // don't cache in the case of errors
this.setState({ this.setState({
isLoading: false, isLoading: false,
@ -348,14 +352,15 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
events: events events: events
}); });
return; return;
}).catch((error: any) => { }
catch (error) {
console.log("Exception returned by getEvents", error.message); console.log("Exception returned by getEvents", error.message);
this.setState({ this.setState({
isLoading: false, isLoading: false,
error: error.message, error: error.message,
events: [] events: []
}); });
}); }
} }
} }
} }

View File

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

View File

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