Initial commit (#512)
This commit is contained in:
parent
ae42943015
commit
6bd2f7d66a
|
@ -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
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"msjsdiag.debugger-for-chrome"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"@microsoft/generator-sharepoint": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"libraryName": "react-calendar-feed",
|
||||||
|
"libraryId": "dd42aa00-b07d-48a2-8896-cc2f8c0d3fae",
|
||||||
|
"environment": "spo"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 |
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://dev.office.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||||
|
"deployCdnPath": "temp/deploy"
|
||||||
|
}
|
|
@ -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 -->"
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://dev.office.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||||
|
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||||
|
}
|
|
@ -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);
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface ICarouselContainerProps { }
|
||||||
|
|
||||||
|
export interface ICarouselContainerState { }
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./CarouselContainer";
|
||||||
|
export * from "./CarouselContainer.types";
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./DateBox";
|
||||||
|
export * from "./DateBox.types";
|
|
@ -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;
|
||||||
|
}
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { ICalendarEvent } from "../../../../lib/shared/services/CalendarService";
|
||||||
|
|
||||||
|
export interface IEventCardProps {
|
||||||
|
isEditMode: boolean;
|
||||||
|
event: ICalendarEvent;
|
||||||
|
isNarrow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEventCardState {}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./EventCard";
|
||||||
|
export * from "./EventCard.types";
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface IPagingProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
itemsCountPerPage: number;
|
||||||
|
showPageNum: boolean;
|
||||||
|
onPageUpdate: (pageNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPagingState { }
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./Paging";
|
||||||
|
export * from "./Paging.types";
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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[]>;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./MockCalendarService";
|
|
@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./RSSCalendarService";
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./IWordPressFullCalendarEventResponse";
|
||||||
|
export * from "./WordPressFullCalendarService";
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./iCalCalendarService";
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./ICalendarEvent";
|
||||||
|
export * from "./ICalendarService";
|
||||||
|
export * from "./CalendarEventRange";
|
||||||
|
export * from "./CalendarServiceProviderList";
|
|
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
});
|
58
samples/react-calendar-feed/src/webparts/calendarFeedSummary/loc/mystrings.d.ts
vendored
Normal file
58
samples/react-calendar-feed/src/webparts/calendarFeedSummary/loc/mystrings.d.ts
vendored
Normal 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;
|
||||||
|
}
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue