Initial commit (#512)

This commit is contained in:
Hugo Bernier 2018-06-07 07:09:40 -04:00 committed by Vesa Juvonen
parent ae42943015
commit 6bd2f7d66a
59 changed files with 20268 additions and 0 deletions

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

32
samples/react-calendar-feed/.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

View File

@ -0,0 +1,5 @@
{
"recommendations": [
"msjsdiag.debugger-for-chrome"
]
}

View File

@ -0,0 +1,41 @@
{
/**
* Install Chrome Debugger Extension for Visual Studio Code to debug your components with the
* Chrome browser: https://aka.ms/spfx-debugger-extensions
*/
"version": "0.2.0",
"configurations": [{
"name": "Local workbench",
"type": "chrome",
"request": "launch",
"url": "https://localhost:4321/temp/workbench.html",
"webRoot": "${workspaceRoot}",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../../src/*": "${webRoot}/src/*"
},
"runtimeArgs": [
"--remote-debugging-port=9222"
]
},
{
"name": "Hosted workbench",
"type": "chrome",
"request": "launch",
"url": "https://enter-your-SharePoint-site/_layouts/workbench.aspx",
"webRoot": "${workspaceRoot}",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../../src/*": "${webRoot}/src/*"
},
"runtimeArgs": [
"--remote-debugging-port=9222",
"-incognito"
]
}
]
}

View File

@ -0,0 +1,13 @@
// Place your settings in this file to overwrite default and user settings.
{
// Configure glob patterns for excluding files and folders in the file explorer.
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"**/bower_components": true,
"**/coverage": true,
"**/lib-amd": true,
"src/**/*.scss.ts": true
},
"typescript.tsdk": ".\\node_modules\\typescript\\lib"
}

View File

@ -0,0 +1,8 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.4.1",
"libraryName": "react-calendar-feed",
"libraryId": "dd42aa00-b07d-48a2-8896-cc2f8c0d3fae",
"environment": "spo"
}
}

View File

@ -0,0 +1,83 @@
# React Calendar Feed Web Part
## 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.
![The web part in action](./assets/react-calendar-feed-demo.gif)
The web part was designed to allow other calendar feed types (or any other type of data you'd like to show as events). If you have additional feeds that you'd like to support, please contact the author or submit a pull request.
Like the SharePoint event web parts, this web part renders a film-strip view when placed on a single column page, and renders a list view when placed in narrow column (e.g.: 3 column layout), or when viewed on a mobile device.
To improve performance, the web part caches the events to the user's local storage (so that it doesn't retrieve the events every time the user visits the page). You can turn off the cache by setting the cache duration to 0 minutes.
For more information about how this solution was built, including some design decisions and information on how you can extend this example to allow additional event feed provider, visit https://tahoeninjas.blog/creating-a-calendar-feed-web-part.
## Used SharePoint Framework Version
![SPFx v1.4.1](https://img.shields.io/badge/SPFx-1.4.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)
## 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
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.
If your feed supports filtering by dates, you can specify **{s}** in the URL where the start date should be inserted, and the web part will automatically replace the **{s}** placeholder with today's date. Similarly, you can specify **{e}** in the URL where you wish the end date to be inserted, and the web part will automatically replace the placeholder for the end date, as determined by the date range you select.
## Solution
Solution|Author(s)
--------|---------
react-calendar-feed | Hugo Bernier ([Tahoe Ninjas](http://tahoeninjas.blog), @bernierh)
## Version history
Version|Date|Comments
-------|----|--------
1.0|May 15, 2018|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## 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.
- 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
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-calendar-feed" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@ -0,0 +1,20 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"calendar-feed-summary-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/calendarFeedSummary/CalendarFeedSummaryWebPart.js",
"manifest": "./src/webparts/calendarFeedSummary/CalendarFeedSummaryWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"CalendarFeedSummaryWebPartStrings": "lib/webparts/calendarFeedSummary/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "react-calendar-feed",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,12 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "react-calendar-feed-client-side-solution",
"id": "dd42aa00-b07d-48a2-8896-cc2f8c0d3fae",
"version": "1.0.0.0",
"includeClientSideAssets": true
},
"paths": {
"zippedPackage": "solution/react-calendar-feed.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://dev.office.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,45 @@
{
"$schema": "https://dev.office.com/json-schemas/core-build/tslint.schema.json",
// Display errors as warnings
"displayAsWarning": true,
// The TSLint task may have been configured with several custom lint rules
// before this config file is read (for example lint rules from the tslint-microsoft-contrib
// project). If true, this flag will deactivate any of these rules.
"removeExistingRules": true,
// When true, the TSLint task is configured with some default TSLint "rules.":
"useDefaultConfigAsBase": false,
// Since removeExistingRules=true and useDefaultConfigAsBase=false, there will be no lint rules
// which are active, other than the list of rules below.
"lintConfig": {
// Opt-in to Lint rules which help to eliminate bugs in JavaScript
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-case": true,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"valid-typeof": true,
"variable-name": false,
"whitespace": false
}
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,7 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.initialize(gulp);

17473
samples/react-calendar-feed/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "react-calendar-feed",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "~1.4.1",
"@microsoft/sp-lodash-subset": "~1.4.1",
"@microsoft/sp-office-ui-fabric-core": "~1.4.1",
"@microsoft/sp-webpart-base": "~1.4.1",
"@pnp/spfx-controls-react": "^1.3.0",
"@pnp/spfx-property-controls": "^1.6.0",
"@types/react": "15.6.6",
"@types/react-dom": "15.5.6",
"@types/webpack-env": ">=1.12.1 <1.14.0",
"ical.js": "^1.2.2",
"ics-js": "^0.10.2",
"moment": "^2.22.1",
"react": "15.6.2",
"react-dom": "15.6.2",
"react-slick": "^0.23.1",
"slick-carousel": "^1.8.1",
"xml-js": "^1.6.2",
"xml2js": "^0.4.19"
},
"devDependencies": {
"@microsoft/sp-build-web": "~1.4.1",
"@microsoft/sp-module-interfaces": "~1.4.1",
"@microsoft/sp-webpart-workbench": "~1.4.1",
"@types/chai": ">=3.4.34 <3.6.0",
"@types/mocha": ">=2.2.33 <2.6.0",
"ajv": "~5.2.2",
"gulp": "~3.9.1"
}
}

View File

@ -0,0 +1,66 @@
$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

@ -0,0 +1,78 @@
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> {
var settings: any = {
accessibility: true,
arrows: false,
autoplaySpeed: 5000,
dots: true,
infinite: true,
slidesToShow: 3,
slidesToScroll: 3,
speed: 500,
centerPadding: "50px",
pauseOnHover: true,
variableWidth: false,
useCSS: true,
responsive: [
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
}
},
{
breakpoint: 1000,
settings: {
slidesToShow: 4,
slidesToScroll: 4,
}
},
]
};
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

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

View File

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

View File

@ -0,0 +1,130 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
$neutralPrimary: '[theme:neutralPrimary,default:#333333]';
.box {
font-weight: 400;
border: 1px solid;
color: $neutralPrimary;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex-align: center;
align-items: center;
text-align: center;
}
.box.boxIsSingleDay {
-ms-flex-pack: center;
justify-content: center;
}
.box.boxIsMultipleDays {
-ms-flex-pack: justify;
justify-content: space-between;
}
.date {
-ms-flex-positive: 1;
flex-grow: 1;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex-pack: center;
justify-content: center;
}
.boxIsSmall .date {
font-size: 10px;
font-weight: 600;
}
.boxIsMedium .date {
font-size: 12px;
font-weight: 400;
}
.boxIsLarge .date {
font-size: 15px;
font-weight: 400;
}
.day {
font-weight: 600;
}
.boxIsSmall .month {
font-size: 12px;
font-weight: 400;
}
.boxIsMedium .month {
font-size: 15px;
font-weight: 400;
}
.boxIsLarge .month {
font-size: 21px;
font-weight: 100;
}
.day {
font-weight: 600;
}
.boxIsSmall .day {
font-size: 21px;
}
.boxIsLarge .day,
.boxIsMedium .day {
font-size: 28px;
}
.box.boxIsSmall {
height: 62px;
width: 62px;
.month {
font-size: 12px;
font-weight: 400;
}
.day {
font-size: 21px;
}
}
.box.boxIsMedium {
height: 80px;
width: 80px;
}
.box.boxIsLarge {
height: 104px;
width: 104px;
}
.boxIsLarge .day,
.boxIsMedium .day {
font-size: 28px;
}
.separator {
margin: 0;
border: 0;
border-bottom: 1px solid;
}
.boxIsSmall .separator {
width: 31px;
}
.boxIsMedium .separator {
width: 40px;
}
.boxIsLarge .separator {
width: 52px;
}

View File

@ -0,0 +1,53 @@
import * as moment from "moment";
import { css } from "office-ui-fabric-react/lib/Utilities";
import * as React from "react";
import styles from "./DateBox.module.scss";
import { DateBoxSize, IDateBoxProps, IDateBoxState } from "./DateBox.types";
/**
* Shows a date in a SharePoint-looking date
*/
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);
// check if both dates are on the same day
const isSameDay: boolean = startMoment.isSame(endMoment, "day");
if (isSameDay) {
return this._renderSingleDay(startMoment);
} else {
return this._renderMultiDay(startMoment, endMoment);
}
}
private _renderSingleDay(startMoment: moment.Moment): JSX.Element {
const { className, size } = this.props;
return (
<div className={css(styles.box,
styles.boxIsSingleDay,
(size === DateBoxSize.Small ? styles.boxIsSmall : styles.boxIsMedium), className)}
data-automation-id="singleDayDayContainer">
<div className={styles.month}
data-automation-id="singleDayMonthContainer">{startMoment.format("MMM").toUpperCase()}</div>
<div className={styles.day}
data-automation-id="singleDayDayContainer">{startMoment.format("D")}</div>
</div>);
}
private _renderMultiDay(startMoment: moment.Moment, endMoment: moment.Moment): JSX.Element {
const { className, size } = this.props;
return (
<div
className={css(styles.box,
styles.boxIsSingleDay,
(size === DateBoxSize.Small ? styles.boxIsSmall : styles.boxIsMedium), className)}
data-automation-id="multipleDayBox">
<div className={styles.date} data-automation-id="multipleDayStartDateContainer">{startMoment.format("MMM D").toUpperCase()}</div>
<hr className={styles.separator} />
<div className={styles.date} data-automation-id="multipleDayEndDateContainer">{endMoment.format("MMM D").toUpperCase()}</div>
</div>);
}
}

View File

@ -0,0 +1,15 @@
export interface IDateBoxProps {
startDate: Date;
endDate: Date;
className?: string;
size: DateBoxSize;
}
export interface IDateBoxState {
// you just proved advertising works!
}
export enum DateBoxSize {
Small,
Medium
}

View File

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

View File

@ -0,0 +1,188 @@
@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]";
.cardWrapper {
border: 1px solid;
border-color: transparent;
box-sizing: border-box;
outline: transparent;
}
.compactCard {
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
padding: 2px !important;
border: none;
height: 68px;
margin-bottom: 12px;
}
.cardWrapper:focus {
border-color: $neutralSecondary;
}
.cardWrapper .dateBox {
border-color: $neutralTertiaryAlt;
}
.normalCard .dateBoxContainer {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex-pack: center;
justify-content: center;
-ms-flex-align: center;
align-items: center;
}
.normalCard .category {
margin-bottom: 4px;
}
.compactCard .dateBox {
margin: auto 14px auto 0;
min-width: 64px;
}
[dir=rtl] .compactCard .dateBox {
margin: auto 0 auto 14px;
}
[dir=ltr] .compactCard .emptyStatePreviewContainer {
margin-right: 14px;
}
.compactCard .title {
font-size: 15px;
font-weight: 400;
margin: 0 0 3px;
max-height: 38px;
line-height: 19px;
}
.category,
.datetime,
.location {
font-size: 12px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal;
height: 18px;
}
.category,
.location {
color: $neutralPrimary;
}
.datetime {
color: $neutralPrimary;
}
.title {
display: block;
overflow: hidden;
}
.addToMyCalendar,
.title {
font-weight: 400;
color: $neutralPrimary;
}
.addToMyCalendar {
font-size: 14px;
margin-top: 10px;
}
[dir=ltr] .addToMyCalendar {
margin-left: -8px;
}
[dir=rtl] .addToMyCalendar {
margin-right: -8px;
}
.root {
width: 100%;
align-items: center;
-webkit-font-smoothing: antialiased;
background-color: $white;
border: 1px solid #eaeaea;
-webkit-box-sizing: border-box;
box-sizing: border-box;
max-width: 320px;
min-width: 206px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
position: relative;
}
.rootIsCompact {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
max-width: 480px;
height: 68px;
border: 1px solid transparent;
}
.rootIsActionable {
color: inherit;
}
.rootIsActionable:hover {
border-color: $neutralLight;
}
.dateBox {
border-color: $neutralTertiaryAlt;
}
.normalCard .title {
font-size: 17px;
font-weight: 300;
margin-bottom: 20px;
height: 44px;
line-height: 22px;
}
.normalCard .detailsContainer {
padding: 0 16px 16px;
height: 172px;
max-width: 320px;
min-width: 206px;
}
.category,
.location {
color:$neutralSecondary;
}
.category,
.datetime,
.location {
font-size: 12px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal;
height: 18px;
}
:global(.slick-slide) .cardWrapper {
padding-left:8px;
padding-right:8px;
}

View File

@ -0,0 +1,177 @@
import { Guid } from "@microsoft/sp-core-library";
import * as strings from "CalendarFeedSummaryWebPartStrings";
import * as ICS from "ics-js";
import * as moment from "moment";
import { ActionButton, DocumentCard, DocumentCardType, FocusZone, css } from "office-ui-fabric-react";
import * as React from "react";
import { IEventCardProps, IEventCardState } from ".";
import { DateBox, DateBoxSize } from "../DateBox";
import styles from "./EventCard.module.scss";
const AllDayFormat: string = "dddd, MMMM Do YYYY";
const LocalizedTimeFormat: string = "llll";
/**
* Shows an event in a document card
*/
export class EventCard extends React.Component<IEventCardProps, IEventCardState> {
public render(): React.ReactElement<IEventCardProps> {
const { isNarrow } = this.props;
if (isNarrow) {
return this._renderNarrowCell();
} else {
return this._renderNormalCell();
}
}
private _renderNormalCell(): JSX.Element {
const { start,
end,
allDay,
title,
url,
category,
description,
location } = this.props.event;
const eventDate: moment.Moment = moment(start);
const dateString: string = allDay ? eventDate.format(AllDayFormat) : eventDate.format(LocalizedTimeFormat);
const { isEditMode } = this.props;
return (
<div>
<div
className={css(styles.cardWrapper)}
data-is-focusable={true}
data-is-focus-item={true}
role="listitem"
aria-label={strings.EventCardWrapperArialLabel.replace("{0}", title).replace("{1}", `${dateString}`)}
tabIndex={0}
>
<DocumentCard
className={css(styles.root, !isEditMode && styles.rootIsActionable, styles.normalCard)}
type={DocumentCardType.normal}
onClickHref={isEditMode ? null : url}
>
<FocusZone>
<div className={styles.dateBoxContainer} style={{ height: 160 }} data-automation-id="normal-card-preview">
<DateBox
className={styles.dateBox}
startDate={start}
endDate={end}
size={DateBoxSize.Medium}
/>
</div>
<div className={styles.detailsContainer}>
<div className={styles.category}>{category}</div>
<div className={styles.title} data-automation-id="event-card-title">{title}</div>
<div className={styles.datetime}>{dateString}</div>
<div className={styles.location}>{location}</div>
<ActionButton
className={styles.addToMyCalendar}
iconProps={{ iconName: "AddEvent" }}
ariaLabel={strings.AddToCalendarAriaLabel}
onClick={this._onAddToMyCalendar}
>
{strings.AddToCalendarButtonLabel}
</ActionButton>
</div>
</FocusZone>
</DocumentCard>
</div>
</div>
);
}
private _renderNarrowCell(): JSX.Element {
const { start,
end,
allDay,
title,
url,
category,
location } = this.props.event;
const eventDate: moment.Moment = moment.utc(start);
const dateString: string = allDay ? eventDate.format(AllDayFormat) : eventDate.format(LocalizedTimeFormat);
return (
<div>
<div
className={css(styles.cardWrapper, styles.compactCard, styles.root, styles.rootIsCompact)}
data-is-focusable={true}
data-is-focus-item={true}
role="listitem"
aria-label={strings.EventCardWrapperArialLabel.replace("{0}", title).replace("{1}", dateString)}
>
<DocumentCard
className={css(styles.root, styles.rootIsActionable, styles.rootIsCompact)}
type={DocumentCardType.compact}
onClickHref={url}
>
<div data-automation-id="normal-card-preview">
<DateBox
className={styles.dateBox}
startDate={start}
endDate={end}
size={DateBoxSize.Small}
/>
</div>
<div>
<div className={styles.category}>{category}</div>
<div className={styles.title} data-automation-id="event-card-title">{title}</div>
<div className={styles.datetime}>{dateString}</div>
<div className={styles.location}>{location}</div>
</div>
</DocumentCard>
</div>
</div>
);
}
private _onAddToMyCalendar = (): void => {
const { event } = this.props;
// create a calendar to hold the event
const cal: ICS.VCALENDAR = new ICS.VCALENDAR();
cal.addProp("VERSION", 2.0);
cal.addProp("PRODID", "//SPFX//NONSGML v1.0//EN");
// create an event
const icsEvent: ICS.VEVENT = new ICS.VEVENT();
// generate a unique id
icsEvent.addProp("UID", Guid.newGuid().toString());
// if the event is all day, just pass the date component
if (event.allDay) {
icsEvent.addProp("DTSTAMP", event.start, { VALUE: "DATE" });
icsEvent.addProp("DTSTART", event.start, { VALUE: "DATE" });
} else {
icsEvent.addProp("DTSTAMP", event.start, { VALUE: "DATE-TIME" });
icsEvent.addProp("DTSTART", event.start, { VALUE: "DATE-TIME" });
icsEvent.addProp("DTEND", event.start, { VALUE: "DATE-TIME" });
}
// add a title
icsEvent.addProp("SUMMARY", event.title);
// add a url if there is one
if (event.url !== undefined) {
icsEvent.addProp("URL", event.url);
}
// add a description if there is one
if (event.description !== undefined) {
icsEvent.addProp("DESCRIPTION", event.description);
}
// add a location if there is one
if (event.location !== undefined) {
icsEvent.addProp("LOCATION", event.location);
}
// add the event to the calendar
cal.addComponent(icsEvent);
// export the calendar
// my spidey senses are telling me that there are sitaations where this isn't going to work, but none of my tests could prove it.
// i suspect we're not encoding events properly
window.open("data:text/calendar;charset=utf8," + encodeURIComponent(cal.toString()));
}
}

View File

@ -0,0 +1,9 @@
import { ICalendarEvent } from "../../../../lib/shared/services/CalendarService";
export interface IEventCardProps {
isEditMode: boolean;
event: ICalendarEvent;
isNarrow: boolean;
}
export interface IEventCardState {}

View File

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

View File

@ -0,0 +1,57 @@
.Paging {
width: 100%;
min-width: 240px;
text-align: center;
margin: 0;
padding: 2px 0;
border: 1px solid transparent;
position: relative;
}
.Paging .next,
.Paging .prev {
margin: 0;
display: inline-block;
border: none;
}
.Paging button {
font-size: 14px;
font-weight: 400;
padding: 8px 4px;
margin: 0 8px;
cursor: pointer;
cursor: hand;
position: relative;
height: 32px!important;
display: none;
outline: 0;
}
.Paging.noPageNum {
text-align: left;
}
.noPageNum .next {
float: right;
}
.Paging .next,
.Paging .prev {
margin: 0;
display: inline-block;
border: none;
}
.Paging button {
font-size: 14px;
font-weight: 400;
padding: 8px 4px;
margin: 0 8px;
cursor: pointer;
cursor: hand;
position: relative;
height: 32px!important;
display: none;
outline: 0;
}

View File

@ -0,0 +1,88 @@
import { ActionButton, IButtonProps } from "office-ui-fabric-react/lib/Button";
import { Icon } from "office-ui-fabric-react/lib/Icon";
import { css } from "office-ui-fabric-react/lib/Utilities";
import * as React from "react";
import { IPagingProps, IPagingState } from ".";
import styles from "./Paging.module.scss";
import * as strings from "CalendarFeedSummaryWebPartStrings";
/**
* A custom pagination control designed to look & feel like Office UI Fabric
*/
export class Paging extends React.Component<IPagingProps, IPagingState> {
public render(): React.ReactElement<IPagingProps> {
const { totalItems, itemsCountPerPage, currentPage } = this.props;
// calculate the page situation
const numberOfPages: number = this._getNumberOfPages();
// we disable the previous button if we're on page 1
const prevDisabled: boolean = currentPage < 1;
// we disable the next button if we're on the last page
const nextDisabled: boolean = currentPage >= numberOfPages;
return (
<div className={css(styles.Paging, this.props.showPageNum ? null : styles.noPageNum)}>
<ActionButton className={styles.prev}
data-automation-id="previousPage"
onRenderIcon={(props: IButtonProps) => {
// we use the render custom icon method to render the icon consistently with the right icon
return (
<Icon iconName="ChevronLeft" />
);
}}
disabled={prevDisabled}
onClick={this._prevPage}
ariaLabel={strings.PrevButtonAriaLabel}
>
{strings.PrevButtonLabel}
</ActionButton>
{/* NOT IMPLEMENTED: Page numbers aren't shown here, but we'll need them if we want this control to be reusable */}
<ActionButton className={styles.next}
data-automation-id="nextPage"
disabled={nextDisabled}
onRenderMenuIcon={(props: IButtonProps) => {
// we use the render custom menu icon method to render the icon to the right of the text
return (
<Icon iconName="ChevronRight" />
);
}}
onClick={this._nextPage}
ariaLabel={strings.NextButtonAriaLabel}
>
{strings.NextButtonLabel}
</ActionButton>
</div>
);
}
/**
* Increments the page number unless we're on the last page
*/
private _nextPage = (): void => {
const numberOfPages: number = this._getNumberOfPages();
if (this.props.currentPage < numberOfPages) {
this.props.onPageUpdate(this.props.currentPage + 1);
}
}
/**
* Decrements the page number unless we're on the first page
*/
private _prevPage = (): void => {
if (this.props.currentPage > 1) {
this.props.onPageUpdate(this.props.currentPage - 1);
}
}
/**
* Calculates how many pages there will be
*/
private _getNumberOfPages(): number {
const { totalItems, itemsCountPerPage } = this.props;
let numPages: number = Math.round(totalItems / itemsCountPerPage);
return numPages;
}
}

View File

@ -0,0 +1,9 @@
export interface IPagingProps {
currentPage: number;
totalItems: number;
itemsCountPerPage: number;
showPageNum: boolean;
onPageUpdate: (pageNumber: number) => void;
}
export interface IPagingState { }

View File

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

View File

@ -0,0 +1,96 @@
import { HttpClient, HttpClientResponse } from "@microsoft/sp-http";
import { IWebPartContext } from "@microsoft/sp-webpart-base";
import * as moment from "moment";
import { CalendarEventRange } from ".";
import { ICalendarEvent } from "./ICalendarEvent";
import { ICalendarService } from "./ICalendarService";
/**
* Base Calendar Service
* Implements some generic methods that can be used by ICalendarService providers.
* Each provider can also implement their own ways to retrieve and parse events, if they
* 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 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;
}
/**
* 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 endMoment: moment.Moment = moment(dateRange.End);
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.UseCORS ?
`https://cors-anywhere.herokuapp.com/${feedUrl}` :
feedUrl;
return this.Context.httpClient.fetch(requestUrl,
HttpClient.configurations.v1, {});
}
/**
* 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

@ -0,0 +1,50 @@
import * as moment from "moment";
export enum DateRange {
OneWeek,
TwoWeeks,
Month,
Quarter,
Year,
}
export class CalendarEventRange {
public Start: Date;
public End: Date;
public DateRange: DateRange;
constructor(range: DateRange) {
this.Start = moment().toDate();
this.DateRange = range;
this.End = this._getRangeEnd();
}
private _getRangeEnd(): Date {
let end: moment.Moment;
// add the appropriate number of days
switch (this.DateRange) {
case DateRange.OneWeek:
end = moment().add(1, "weeks");
break;
case DateRange.TwoWeeks:
end = moment().add(2, "weeks");
break;
case DateRange.Month:
end = moment().add(1, "months");
break;
case DateRange.Quarter:
end = moment().add(1, "quarters");
break;
default:
// is there a max date option in Moment? i couldn't find it
// instead, let's get events for the next year
end = moment().add(1, "years");
break;
}
return end.toDate();
}
}

View File

@ -0,0 +1,46 @@
import { MockCalendarService } from "./MockCalendarService";
import { RSSCalendarService } from "./RSSCalendarService";
import { WordPressFullCalendarService } from "./WordPressFullCalendarService";
import { iCalCalendarService } from "./iCalCalendarService";
export class CalendarServiceProviderList {
public static getProviders(): any[] {
const providers: any[] = [];
// only include the Mock service provider in DEBUG
if (DEBUG) {
providers.push({
label: "Mock",
key: "mock",
initialize: () => new MockCalendarService()
});
}
providers.push({
label: "WordPress",
key: "wordpress",
initialize: () => new WordPressFullCalendarService()
});
providers.push({
label: "iCal",
key: "ical",
initialize: () => new iCalCalendarService()
});
providers.push({
label: "RSS",
key: "RSS",
initialize: () => new RSSCalendarService()
});
return providers;
}
}
export enum CalendarServiceProviderType {
WordPress = "WordPress",
iCal = "iCal",
RSS = "RSS",
Mock = "Mock"
}

View File

@ -0,0 +1,10 @@
export interface ICalendarEvent {
title: string;
start: Date;
end: Date;
url: string|undefined;
allDay: boolean;
category: string|undefined;
description: string|undefined;
location: string|undefined;
}

View File

@ -0,0 +1,12 @@
import { IWebPartContext } from "@microsoft/sp-webpart-base";
import { CalendarEventRange, ICalendarEvent } from ".";
export interface ICalendarService {
Context: IWebPartContext;
FeedUrl: string;
EventRange: CalendarEventRange;
UseCORS: boolean;
CacheDuration: number;
Name: string;
getEvents: () => Promise<ICalendarEvent[]>;
}

View File

@ -0,0 +1,158 @@
/**
* MockExtensionService
* This provider will NOT be listed in the list of available providers when this solution is packaged with --ship.
* Don't freak out, it didn't just disappear.
*/
import * as moment from "moment";
import { BaseCalendarService } from "../BaseCalendarService";
import { ICalendarEvent } from "../ICalendarEvent";
import { ICalendarService } from "../ICalendarService";
const today: Date = new Date();
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").toDate(),
"url": "https://www.contoso.com/news-events/events/2/",
"allDay": true,
"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";
}
public getEvents = (): Promise<ICalendarEvent[]> => {
return new Promise<ICalendarEvent[]>((resolve: any) => {
setTimeout(() => {
resolve(this.filterEventRange(sampleEvents));
}, 1000);
});
}
}

View File

@ -0,0 +1 @@
export * from "./MockCalendarService";

View File

@ -0,0 +1,103 @@
/**
* RSS Calendar Service
* Renders events from an RSS feed. It only renders events in the future, so not every plain old RSS feed will do, but
* calendar RSS feeds should work ok.
* Before anyone complains that I should have used a readily available RSS parser library, I tried almost
* every one I could find on NPM and GitHub and found that they did not meet my needs.
* I'm open to suggestions, though, if you have a library that you think would work better.
*/
import { HttpClientResponse } from "@microsoft/sp-http";
import * as convert from "xml-js";
import { ICalendarService } from "..";
import { BaseCalendarService } from "../BaseCalendarService";
import { ICalendarEvent } from "../ICalendarEvent";
import { escape, unescape } from "@microsoft/sp-lodash-subset";
export class RSSCalendarService extends BaseCalendarService implements ICalendarService {
constructor() {
super();
this.Name = "RSS";
}
public getEvents = (): Promise<ICalendarEvent[]> => {
const parameterizedFeedUrl: string = this.replaceTokens(this.FeedUrl, this.EventRange);
return this.fetchResponse(parameterizedFeedUrl)
.then((response: HttpClientResponse) => response.text())
.then((xml: string): ICalendarEvent[] => {
// convert RSS feed from XML to JSON
const results: any = convert.xml2js(xml, { compact: false });
if (results === undefined) {
throw "No results";
}
// get the RSS element
const rss: any = results.elements[0];
if (rss === undefined) {
throw "No root";
}
// get the first channel in the RSS feed
const channel: any = rss.elements[0];
if (channel === undefined) {
throw "No channel";
}
// get all items in the feed
const items: any[] = channel.elements.filter(e => e.name === "item" && e.type === "element");
// convert each RSS element to an event
let events: ICalendarEvent[] = items.map((item: any) => {
let title: string = this._getElementValue(item, "title");
let link: string = this._getElementValue(item, "link");
let pubDate: Date = new Date(this._getElementValue(item, "pubDate"));
let category: string = this._getElementValue(item, "category");
let description: string = this._getElementValue(item, "description");
return {
title: title,
start: pubDate,
end: pubDate,
url: link,
allDay: true,
description: description,
location: undefined, // no equivalent in RSS
category: undefined // no equivalent in RSS
};
});
return this.filterEventRange(events);
}).catch((error: any) => {
console.log("Exception caught by catch in RSS provider", error);
throw error;
});
}
private _getElementValue(item: any, fieldName: string): string {
if (!item || !item.elements) {
return undefined;
}
// get the elements
const filteredElements: any[] = item.elements.filter(e => e.name === fieldName);
if (filteredElements.length < 1 || filteredElements[0].elements.length < 1) {
return undefined;
}
const firstElement: any = filteredElements[0].elements[0];
switch (firstElement.type) {
case "text":
return firstElement.text;
case "cdata":
let cdata:string = firstElement.cdata;
if (cdata !== undefined) {
cdata = unescape(cdata);
}
return cdata;
}
console.log("Found an RSS field type I didn't know", firstElement.type);
return "";
}
}

View File

@ -0,0 +1 @@
export * from "./RSSCalendarService";

View File

@ -0,0 +1,12 @@
export interface IWordPressFullCalendarEventResponse {
title: string;
color: string;
textColor: string;
borderColor: string;
start: string;
end: string;
url: string;
post_id: number;
event_id: string;
allDay: boolean;
}

View File

@ -0,0 +1,42 @@
/**
* ExtensionService
*/
import { ICalendarService } from "..";
import { BaseCalendarService } from "../BaseCalendarService";
import { ICalendarEvent } from "../ICalendarEvent";
import { IWordPressFullCalendarEventResponse } from "./IWordPressFullCalendarEventResponse";
export class WordPressFullCalendarService extends BaseCalendarService implements ICalendarService {
constructor() {
super();
this.Name = "WordPress";
}
public getEvents = (): 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;
});
}
}

View File

@ -0,0 +1,2 @@
export * from "./IWordPressFullCalendarEventResponse";
export * from "./WordPressFullCalendarService";

View File

@ -0,0 +1,50 @@
/**
* ExtensionService
*/
import { HttpClientResponse } from "@microsoft/sp-http";
import * as ICAL from "ical.js";
import { ICalendarService } from "..";
import { BaseCalendarService } from "../BaseCalendarService";
import { ICalendarEvent } from "../ICalendarEvent";
// tslint:disable-next-line:class-name
export class iCalCalendarService extends BaseCalendarService implements ICalendarService {
constructor() {
super();
this.Name = "iCal";
}
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;
});
}
}

View File

@ -0,0 +1 @@
export * from "./iCalCalendarService";

View File

@ -0,0 +1,4 @@
export * from "./ICalendarEvent";
export * from "./ICalendarService";
export * from "./CalendarEventRange";
export * from "./CalendarServiceProviderList";

View File

@ -0,0 +1,39 @@
{
"$schema": "https://dev.office.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "b83bb343-bc5e-460c-9efd-52de06d32ffa",
"alias": "CalendarFeedSummaryWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"preconfiguredEntries": [
{
"groupId": "cf066440-0614-43d6-98ae-0b31cf14c7c3", // Text, media and content
"group": {
"default": "Text, media and content"
},
"title": {
"default": "Calendar Feed Summary"
},
"description": {
"default": "Shows a summary view of a list of calendar events retrieved from an external feed."
},
// commented out because of bug with base64 icons
// "iconImageUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDEwLjU4IDcuNDEiPjxwYXRoIGQ9Ik0yLjQ4LS4wMVYuNUgxLjJ2Ni4xNWg1LjcyVjYuM0gxLjU0VjIuMmg2LjR2Mi44OGguMjVWLjVINi45MVYwaC0uMzZWLjVIMi44MlYwaC0uMTN6bS0uOTQuODVoLjk0di40M2guMzRWLjg0aDMuNzN2LjQzaC4zNlYuODRoMS4wM3YuOTRoLTYuNHYtLjQ3eiIvPjxwYXRoIGQ9Ik03LjIyIDUuNjh2LS40M2EyLjE2IDIuMTYgMCAwIDEgMi4xNyAyLjE3bC0uNDUtLjAxYy4wMi0uNy0uNDktMS43LTEuNzItMS43M3ptMCAuNzJ2LS40M2MuOTIuMDUgMS40My43IDEuNDQgMS40NWgtLjQzYy0uMDEtLjY2LS40NS0xLTEuMDEtMS4wMnptLjYuN2EuMy4zIDAgMCAxLS4zLjMuMy4zIDAgMCAxLS4zMS0uMy4zLjMgMCAwIDEgLjMtLjI5LjMuMyAwIDAgMSAuMy4zeiIvPjxwYXRoIGQ9Ik0yLjQ4LS4wMVYuNUgxLjJsLjAzIDIuMjMtLjAzIDMuOTJoNS43MlY2LjNIMS41NFYyLjJoNi40djIuODhoLjI1Vi41SDYuOTFWMGgtLjM2Vi41SDIuODJWMGgtLjEzem0tLjk0Ljg1aC45NHYuNDNoLjM0Vi44NGgzLjczdi40M2guMzZWLjg0aDEuMDN2Ljk0aC02LjR2LS40N3oiLz48cGF0aCBkPSJNMi41IDIuNzNoMS42OHYuMzZIMi41em00LjA3LjM2aC4zNHYyLjI4aC0uMzR6bS0xLjM3IDBoLjM1djIuMjhINS4yem0wIDIuMjhoMS43MXYuMzZoLTEuN3ptMC0yLjY0aDEuNzF2LjM2aC0xLjd6Ii8+PHBhdGggZD0iTTMuODYgMy4xaC4zNHYyLjI3aC0uMzR6bS0xLjM2IDBoLjM0djIuMjdIMi41em0wIDIuMjdoMS43di4zNkgyLjV6bTAtMi42NGgxLjd2LjM2SDIuNXoiLz48L3N2Zz4=",
"officeFabricIconFontName": "Calendar",
"properties": {
"title": "Upcoming Events",
"dateRange": 4, /* Year */
"maxEvents": 4,
"useCORS": false,
"cacheDuration": 15 /* 15 minutes */
}
}
]
}

View File

@ -0,0 +1,260 @@
import { Version } from "@microsoft/sp-core-library";
// tslint:disable-next-line:max-line-length
import { BaseClientSideWebPart, IPropertyPaneConfiguration, IPropertyPaneDropdownOption, PropertyPaneDropdown } from "@microsoft/sp-webpart-base";
import { CalloutTriggers } from "@pnp/spfx-property-controls/lib/PropertyFieldHeader";
import { PropertyFieldNumber } from "@pnp/spfx-property-controls/lib/PropertyFieldNumber";
import { PropertyFieldSliderWithCallout } from "@pnp/spfx-property-controls/lib/PropertyFieldSliderWithCallout";
import { PropertyFieldTextWithCallout } from "@pnp/spfx-property-controls/lib/PropertyFieldTextWithCallout";
import { PropertyFieldToggleWithCallout } from "@pnp/spfx-property-controls/lib/PropertyFieldToggleWithCallout";
import * as strings from "CalendarFeedSummaryWebPartStrings";
import * as React from "react";
import * as ReactDom from "react-dom";
import { CalendarEventRange, DateRange, ICalendarService } from "../../shared/services/CalendarService";
import { CalendarServiceProviderList } from "../../shared/services/CalendarService/CalendarServiceProviderList";
import { ICalendarFeedSummaryWebPartProps } from "./CalendarFeedSummaryWebPart.types";
import CalendarFeedSummary from "./components/CalendarFeedSummary";
import { ICalendarFeedSummaryProps } from "./components/CalendarFeedSummary.types";
// this is the same width that the SharePoint events web parts use to render as narrow
const MaxMobileWidth: number = 480;
/**
* Calendar Feed Summary Web Part
* This web part shows a summary of events, in a film-strip (for normal views) or list view (for narrow views)
* It is called a summary web part because it doesn't allow the user to filter events.
*/
export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<ICalendarFeedSummaryWebPartProps> {
// the list of proviers available
private _providerList: any[];
constructor() {
super();
// get the list of providers so that we can offer it to users
this._providerList = CalendarServiceProviderList.getProviders();
}
/**
* Renders the web part
*/
public render(): void {
// see if we need to render a mobile view
const isNarrow: boolean = this.width <= MaxMobileWidth;
// display the summary (or the configuration screen)
const element: React.ReactElement<ICalendarFeedSummaryProps> = React.createElement(
CalendarFeedSummary,
{
title: this.properties.title,
displayMode: this.displayMode,
context: this.context,
isConfigured: this._isConfigured(),
isNarrow: isNarrow,
maxEvents: this.properties.maxEvents,
provider: this._getDataProvider(),
updateProperty: (value: string) => {
this.properties.title = value;
},
}
);
ReactDom.render(element, this.domElement);
}
/**
* We store our configure in version 1.0. If we ever change how we store our configuration information,
* we'll update the version number here.
*/
protected get dataVersion(): Version {
return Version.parse("1.0");
}
/**
* We're disabling reactive property panes here because we don't want the web part to try to update events as
* people are typing in the feed URL.
*/
protected get disableReactivePropertyChanges(): boolean {
// require an apply button on the property pane
return true;
}
/**
* Show the configuration pane
*/
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
// create a drop down of feed providers from our list
const feedTypeOptions: IPropertyPaneDropdownOption[] = this._providerList.map(provider => {
return { key: provider.key, text: provider.label };
});
// make sure to set a default date range if it isn't defined
// somehow this is an issue when binding to properties that are enums
if (this.properties.dateRange === undefined) {
this.properties.dateRange = DateRange.Year;
}
if (this.properties.cacheDuration === undefined) {
// default to 15 minutes
this.properties.cacheDuration = 15;
}
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupFields: [
// feed type drop down. Add your own types in the drop-down list
PropertyPaneDropdown("feedType", {
label: strings.FeedTypeFieldLabel,
options: feedTypeOptions
}),
// feed url input box
PropertyFieldTextWithCallout("feedUrl", {
calloutTrigger: CalloutTriggers.Hover,
key: "feedUrlFieldId",
label: strings.FeedUrlFieldLabel,
calloutContent:
React.createElement("p", {}, strings.FeedUrlCallout),
calloutWidth: 200,
value: this.properties.feedUrl,
deferredValidationTime: 500,
onGetErrorMessage: this._validateFeedUrl.bind(this)
}),
// how days ahead from today are we getting
PropertyPaneDropdown("dateRange", {
label: strings.DateRangeFieldLabel,
options: [
{ key: DateRange.OneWeek, text: strings.DateRangeOptionWeek },
{ key: DateRange.TwoWeeks, text: strings.DateRangeOptionTwoWeeks },
{ key: DateRange.Month, text: strings.DateRangeOptionMonth },
{ key: DateRange.Quarter, text: strings.DateRangeOptionQuarter },
{ key: DateRange.Year, text: strings.DateRangeOptionUpcoming },
]
}),
]
},
// advanced group
{
groupName: strings.AdvancedGroupName,
groupFields: [
// how many items are we diplaying in a page
PropertyFieldNumber("maxEvents", {
key: "maxEventsFieldId",
label: strings.MaxEventsFieldLabel,
description: strings.MaxEventsFieldDescription,
value: this.properties.maxEvents,
minValue: 0,
disabled: false
}),
// use CORS toggle
PropertyFieldToggleWithCallout("useCORS", {
calloutTrigger: CalloutTriggers.Hover,
key: "useCORSFieldId",
label: strings.UseCORSFieldLabel,
calloutWidth: 200,
calloutContent: React.createElement("p", {}, strings.UseCORSFieldCallout),
onText: strings.CORSOn,
offText: strings.CORSOff,
checked: this.properties.useCORS
}),
// cache duration slider
PropertyFieldSliderWithCallout("cacheDuration", {
calloutContent: React.createElement("p", {}, strings.CacheDurationFieldCallout),
calloutTrigger: CalloutTriggers.Hover,
calloutWidth: 200,
key: "cacheDurationFieldId",
label: strings.CacheDurationFieldLabel,
max: 1440,
min: 0,
step: 15,
showValue: true,
value: this.properties.cacheDuration
})
],
}
]
}
]
};
}
/**
* If we get resized, call the Render method so that we can switch between the narrow view and the regular view
*/
protected onAfterResize(newWidth: number): void {
// redraw the web part
this.render();
}
/**
* Returns true if the web part is configured and ready to show events. If it returns false, we'll show the configuration placeholder.
*/
private _isConfigured(): boolean {
const { feedUrl, feedType } = this.properties;
// see if web part has a feed url configured
const hasFeedUrl: boolean = feedUrl !== null
&& feedUrl !== undefined
&& feedUrl !== "";
// see if web part has a feed type configured
const hasFeedType: boolean = feedType !== null
&& feedType !== undefined;
// if we have a feed url and a feed type, we are configured
return hasFeedUrl && hasFeedType;
}
/**
* Validates a URL when users type them in the configuration pane.
* @param feedUrl The URL to validate
*/
private _validateFeedUrl(feedUrl: string): string {
if (feedUrl === null ||
feedUrl.trim().length === 0) {
return strings.FeedUrlValidationNoUrl;
}
if (!feedUrl.match(/(http|https):\/\/(\w+:{0,1}\w*)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%!\-\/]))?/)) {
return strings.FeedUrlValidationInvalidFormat;
} else {
return "";
}
}
/**
* Initialize a feed data provider from the list of existing providers
*/
private _getDataProvider(): ICalendarService {
const {
feedType,
feedUrl,
useCORS,
cacheDuration
} = this.properties;
// get the first provider matching the type selected
let providerItem: any = this._providerList.filter(p => p.key === this.properties.feedType)[0];
// make sure we got a valid provider
if (!providerItem) {
// return nothing. This should only happen if we removed a provider that we used to support or changed our provider keys
return undefined;
}
// get an instance
let provider: ICalendarService = providerItem.initialize();
// pass props
provider.Context = this.context;
provider.FeedUrl = feedUrl;
provider.UseCORS = useCORS;
provider.CacheDuration = cacheDuration;
provider.EventRange = new CalendarEventRange(this.properties.dateRange);
return provider;
}
}

View File

@ -0,0 +1,14 @@
import { DateRange } from "../../shared/services/CalendarService";
/**
* 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: string; // 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
}

View File

@ -0,0 +1,78 @@
@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]";
.calendarFeedSummary {
color: inherit;
}
.webPartChrome {
display: -ms-flexbox;
display: flex;
-ms-flex-align: stretch;
align-items: stretch;
-ms-flex-direction: column;
flex-direction: column;
}
.headerSmMargin {
margin-bottom: 11px;
}
.headerLgMargin {
margin-bottom: 18px;
}
.webPartHeader {
display: -ms-flexbox;
display: flex;
-ms-flex-align: baseline;
align-items: baseline;
}
.seeAll {
white-space: nowrap;
margin-left: auto;
position: relative;
}
.content {
-ms-flex-order: 2;
order: 2;
width: 100%;
}
.list {
width: 100%;
}
.spinner {
padding-top: 20px;
}
.emptyMessage {
overflow: hidden;
display: inline-block;
margin: 10px 0 15px;
color: #666666;
}
.errorMessage {
overflow: hidden;
display: inline-block;
margin: 10px 0 15px;
color: #666666;
}
.errorMessage .moreDetails {
color:$error;
}

View File

@ -0,0 +1,393 @@
import { DisplayMode } from "@microsoft/sp-core-library";
import { SPComponentLoader } from "@microsoft/sp-loader";
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
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";
// the key used when caching events
const CacheKey: string = "calendarFeedSummary";
/**
* Displays a feed summary from a given calendar feed provider. Renders a different view for mobile/narrow web parts.
*/
export default class CalendarFeedSummary extends React.Component<ICalendarFeedSummaryProps, ICalendarFeedSummaryState> {
constructor(props: ICalendarFeedSummaryProps) {
super(props);
this.state = {
isLoading: false,
events: [],
error: undefined,
currentPage: 1
};
// needed for the slick slider in normal mode
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");
}
/**
* When components are mounted, get the events
*/
public componentDidMount(): void {
if (this.props.isConfigured) {
this._loadEvents(true);
}
}
/**
* When someone changes the property pane, it triggers this event. Use it to determine if we need to refresh the events or not
* @param prevProps The previous props before changes are applied
* @param prevState The previous state before changes are applied
*/
public componentDidUpdate(prevProps: ICalendarFeedSummaryProps, prevState: ICalendarFeedSummaryState): void {
// only reload if the provider info has changed
const prevProvider: ICalendarService = prevProps.provider;
const currProvider: ICalendarService = this.props.provider;
// if there isn't a current provider, do nothing
if (currProvider === undefined) {
return;
}
// if we didn't have a provider and now we do, we definitely need to update
if (prevProvider === undefined) {
if (currProvider !== undefined) {
this._loadEvents(false);
}
// there's nothing to do because there isn't a provider
return;
}
const settingsHaveChanged: boolean = prevProvider.CacheDuration !== currProvider.CacheDuration ||
prevProvider.FeedUrl !== currProvider.FeedUrl ||
prevProvider.Name !== currProvider.Name ||
prevProvider.UseCORS !== currProvider.UseCORS;
if (settingsHaveChanged) {
// only load from cache if the providers haven't changed, otherwise reload.
this._loadEvents(false);
}
}
/**
* Renders the view. There can be three different outcomes:
* 1. Web part isn't configured and we show the placeholders
* 2. Web part is configured and we're loading events, or
* 3. Web part is configured and events are loaded
*/
public render(): React.ReactElement<ICalendarFeedSummaryProps> {
const {
isConfigured,
isNarrow,
} = this.props;
const {
events,
isLoading,
error
} = this.state;
// if we're not configured, show the placeholder
if (!isConfigured) {
return <Placeholder
iconName="Calendar"
iconText={strings.PlaceholderTitle}
description={strings.PlaceholderDescription}
buttonLabel={strings.ConfigureButton}
onConfigure={this._onConfigure} />;
}
// we're configured, let's show stuff
// put everything together in a nice little calendar view
return (
<div className={css(styles.calendarFeedSummary, styles.webPartChrome)}>
<div className={css(styles.webPartHeader, styles.headerSmMargin)}>
<WebPartTitle displayMode={this.props.displayMode}
title={this.props.title}
updateProperty={this.props.updateProperty}
/>
</div>
<div className={styles.content}>
{this._renderContent()}
</div>
</div>
);
}
/**
* Render your web part content
*/
private _renderContent(): JSX.Element {
const {
isNarrow,
displayMode
} = this.props;
const {
events,
isLoading,
error
} = this.state;
const isEditMode: boolean = displayMode === DisplayMode.Edit;
const hasErrors: boolean = error !== undefined;
const hasEvents: boolean = events.length > 0;
if (isLoading) {
// we're currently loading
return (<div className={styles.spinner}><Spinner label={strings.Loading} /></div>);
}
if (hasErrors) {
// we're done loading but got some errors
if (!isEditMode) {
// otherwise, just show a friendly message
return (<div className={styles.errorMessage}>{strings.ErrorMessage}</div>);
} else {
// render a more advanced diagnostic of what went wrong
return this._renderError();
}
}
if (!hasEvents) {
// we're done loading, no errors, but have no events
return (<div className={styles.emptyMessage}>{strings.NoEventsMessage}</div>);
}
// we're loaded, no errors, and got some events
if (isNarrow) {
return this._renderNarrowList();
} else {
return this._renderNormalList();
}
}
/**
* Tries to make sense of the returned error messages and provides
* (hopefully) helpful guidance on how to fix the issue.
* It isn't the best piece of coding I've seen. I'm open to suggested improvements
*/
private _renderError(): JSX.Element {
const { error } = this.state;
const { provider } = this.props;
let errorMsg: string = strings.ErrorMessage;
switch (error) {
case "Not Found":
errorMsg = strings.ErrorNotFound;
break;
case "Failed to fetch":
if (!provider.UseCORS) {
// maybe it is because of mixed content?
if (provider.FeedUrl.toLowerCase().substr(0, 7) === "http://") {
errorMsg = strings.ErrorMixedContent;
} else {
errorMsg = strings.ErrorFailedToFetchNoProxy;
}
} else {
errorMsg = strings.ErrorFailedToFetch;
}
break;
default:
// specific provider messages
if (provider.Name === CalendarServiceProviderType.RSS) {
switch (error) {
case "No result":
errorMsg = strings.ErrorRssNoResult;
break;
case "No root":
errorMsg = strings.ErrorRssNoRoot;
break;
case "No channel":
errorMsg = strings.ErrorRssNoChannel;
break;
}
} else if (provider.Name === CalendarServiceProviderType.iCal &&
error.indexOf("Unable to get property 'property' of undefined or null reference") !== -1) {
errorMsg = strings.ErrorInvalidiCalFeed;
} else if (provider.Name === CalendarServiceProviderType.WordPress && error.indexOf("Failed to read") !== -1) {
errorMsg = strings.ErrorInvalidWordPressFeed;
}
}
return (<div className={styles.errorMessage} >
<div className={styles.moreDetails}>
{errorMsg}
</div>
</div>);
}
/**
* Renders a narrow view of the calendar feed when the webpart is less than 480 pixels
*/
private _renderNarrowList(): JSX.Element {
const {
isLoading,
events,
currentPage
} = this.state;
const { maxEvents } = this.props;
// if we're in edit mode, let's not make the events clickable
const isEditMode: boolean = this.props.displayMode === DisplayMode.Edit;
let pagedEvents: ICalendarEvent[] = events;
let usePaging: boolean = false;
if (maxEvents > 0 && events.length > maxEvents) {
// calculate the page size
const pageStartAt: number = maxEvents * (currentPage - 1);
const pageEndAt: number = (maxEvents * currentPage);
pagedEvents = events.slice(pageStartAt, pageEndAt);
usePaging = true;
}
return (<FocusZone
direction={FocusZoneDirection.vertical}
isCircularNavigation={false}
data-automation-id={"narrow-list"}
aria-label={isEditMode ? strings.FocusZoneAriaLabelEditMode : strings.FocusZoneAriaLabelReadMode}
>
<List
items={pagedEvents}
onRenderCell={(item, index) => (
<EventCard
isEditMode={isEditMode}
event={item}
isNarrow={true} />
)} />
{usePaging &&
<Paging
showPageNum={false}
currentPage={currentPage}
itemsCountPerPage={maxEvents}
totalItems={events.length}
onPageUpdate={this._onPageUpdate} />
}
</FocusZone>
);
}
private _onPageUpdate = (pageNumber: number): void => {
this.setState({
currentPage: pageNumber
});
}
/**
* Render a normal view for devices that are wider than 480
*/
private _renderNormalList(): JSX.Element {
const {
events,
isLoading } = this.state;
const isEditMode: boolean = this.props.displayMode === DisplayMode.Edit;
return (<div>
<div>
<div role="application">
<CarouselContainer >
{events.map((event: ICalendarEvent, index: number) => {
return (<EventCard
key={`eventCard${index}`}
isEditMode={isEditMode}
event={event}
isNarrow={false} />);
})}
</CarouselContainer>
</div>
</div>
</div>);
}
/**
* When users click on the Configure button, we display the property pane
*/
private _onConfigure = () => {
this.props.context.propertyPane.open();
}
/**
* Load events from the cache or, if expired, load from the event provider
*/
private _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
if (useCacheIfPossible && localStorage.getItem(CacheKey)) {
let feedCache: IFeedCache = JSON.parse(localStorage.getItem(CacheKey));
const { Name, FeedUrl } = this.props.provider;
const cacheStillValid: boolean = moment().isBefore(feedCache.expiry);
// make sure the cache hasn't expired or that the settings haven't changed
if (cacheStillValid && feedCache.feedType === Name && feedCache.feedUrl === FeedUrl) {
this.setState({
isLoading: false,
events: feedCache.events
});
return;
}
}
// nothing in cache, load fresh
const dataProvider: ICalendarService = this.props.provider;
if (dataProvider) {
this.setState({
isLoading: true
});
return dataProvider.getEvents().then((events: ICalendarEvent[]) => {
// don't cache in the case of errors
this.setState({
isLoading: false,
error: undefined,
events: events
});
return;
}).catch((error: any) => {
console.log("Exception returned by getEvents", error.message);
this.setState({
isLoading: false,
error: error.message,
events: []
});
});
}
}
/**
* Saves events in cache with an expiry date
* @param events an array of events to save in cache
*/
private _setCache(events: ICalendarEvent[]): void {
const { Name, FeedUrl, CacheDuration } = this.props.provider;
// don't cache if we haven't set a cache duration
if (CacheDuration === undefined || CacheDuration === 0) {
return;
}
const expiry: moment.Moment = moment().add(CacheDuration, "minutes");
// we use minutes instead of milliseconds, it doesn't make sense that
// people want to cache a feed for milliseconds, or seconds
// but feel free to change it to suit your needs
const cache: IFeedCache = {
feedType: Name,
feedUrl: FeedUrl,
events: events,
expiry: expiry
};
localStorage.setItem(CacheKey, JSON.stringify(cache));
}
}

View File

@ -0,0 +1,45 @@
/**
* CalendarFeedSummary Types
* Contains the various types used by the component.
* (I like to keep my props and state in a separate ".types"
* file because that's what the Office UI Fabric team does and
* I kinda liked it.
*/
import { DisplayMode } from "@microsoft/sp-core-library";
import { IWebPartContext } from "@microsoft/sp-webpart-base";
import { Moment } from "moment";
import { ICalendarEvent, ICalendarService } from "../../../shared/services/CalendarService";
/**
* The props for the calendar feed summary component
*/
export interface ICalendarFeedSummaryProps {
title: string;
displayMode: DisplayMode;
context: IWebPartContext;
updateProperty: (value: string) => void;
isConfigured: boolean;
isNarrow: boolean;
provider: ICalendarService;
maxEvents: number;
}
/**
* The state for the calendar feed summary component
*/
export interface ICalendarFeedSummaryState {
events: ICalendarEvent[];
error: any|undefined;
isLoading: boolean;
currentPage: number;
}
/**
* Interface to store cached events with an expiry date
*/
export interface IFeedCache {
events: ICalendarEvent[];
expiry: Moment;
feedType: string;
feedUrl: string;
}

View File

@ -0,0 +1,54 @@
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",
"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"
}
});

View File

@ -0,0 +1,58 @@
declare interface ICalendarFeedSummaryWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
AllItemsUrlFieldLabel: string;
FeedUrlFieldLabel: string;
FeedTypeFieldLabel: string;
PlaceholderTitle: string;
PlaceholderDescription: string;
ConfigureButton: string;
FeedTypeOptionGoogle: string;
FeedTypeOptioniCal: string;
FeedTypeOptionRSS: string;
FeedTypeOptionWordPress: string;
FeedUrlCallout: string;
MaxEventsFieldLabel: string;
MaxEventsFieldDescription: string;
DateRangeFieldLabel: string;
DateRangeOptionUpcoming: string;
DateRangeOptionWeek: string;
DateRangeOptionTwoWeeks: string;
DateRangeOptionMonth: string;
DateRangeOptionQuarter: string;
UseCORSFieldLabel: string;
UseCORSFieldCallout: string;
CORSOn: string;
CORSOff: string;
AdvancedGroupName: string;
FocusZoneAriaLabelReadMode: string;
FocusZoneAriaLabelEditMode: string;
EventCardWrapperArialLabel: string;
Loading: string;
NoEventsMessage: string;
CacheDurationFieldLabel: string;
CacheDurationFieldCallout: string;
FeedUrlValidationNoUrl: string;
FeedUrlValidationInvalidFormat: string;
ErrorMessage: string;
NextButtonLabel: string;
PrevButtonLabel: string;
NextButtonAriaLabel: string;
PrevButtonAriaLabel: string;
ErrorNotFound: string;
ErrorMixedContent: string;
ErrorFailedToFetch: string;
ErrorFailedToFetchNoProxy: string;
ErrorRssNoResult: string;
ErrorRssNoRoot: string;
ErrorRssNoChannel: string;
ErrorInvalidiCalFeed: string;
ErrorInvalidWordPressFeed: string;
AddToCalendarAriaLabel: string;
AddToCalendarButtonLabel: string;
}
declare module 'CalendarFeedSummaryWebPartStrings' {
const strings: ICalendarFeedSummaryWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "commonjs",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection"
]
}
}