Merge pull request #1229 from hugoabernier/react-calendar-feed-110

UPDATED SAMPLE: react-calendar-feed
This commit is contained in:
Hugo Bernier 2020-04-18 18:38:39 -04:00 committed by GitHub
commit 5b3634043a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 6555 additions and 3592 deletions

View File

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

View File

@ -24,7 +24,7 @@ For more information about how this solution was built, including some design de
## Used SharePoint Framework Version
![SPFx v1.9.1](https://img.shields.io/badge/SPFx-1.9.1-green.svg)
![SPFx v1.10.0](https://img.shields.io/badge/SPFx-1.10.0-green.svg)
## Applies to
@ -40,9 +40,9 @@ Before you can use this web part example, you will need one of the following:
- A WordPress WP-FullCalendar feed
- An Exchange Public Calendar
It is important that all feeds do not require authentication. Also, make sure that your calendar includes upcoming events, as the web part will filter out evens that are earlier than today's date.
This web part only supports anonymous external feeds. 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.
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
@ -59,6 +59,7 @@ Version|Date|Comments
3.0|November 9, 2018|Converted to SPFx 1.7; Added SharePoint Calendar feed
4.0|January 16, 2019|Converted to SPFx 1.7.1; Removed NPM libraries associated with issue #708.
5.0|August 17, 2019|Converted to SPFx 1.9.1; Refreshed carousel code; Addresses #735, #909. Also added **Convert from UTC** option to handle feeds which do not provide time zone information.
5.1|April 16, 2020|Converted to SPFx 1.10.0; Fixed issue with UTC mode when in narrow view. Updated resizing behavior and styles to match OOB calendar view. Added support for themes and theme variants.
## Disclaimer

View File

@ -3,7 +3,7 @@
"solution": {
"name": "react-calendar-feed-client-side-solution",
"id": "25653136-fc83-4abe-b9d2-a4ac041959d5",
"version": "1.0.0.0",
"version": "5.1.0.0",
"includeClientSideAssets": true
},
"paths": {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "react-calendar-feed",
"version": "1.1.0",
"version": "5.1.0",
"private": true,
"main": "lib/index.js",
"engines": {
@ -12,10 +12,11 @@
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-webpart-base": "1.9.1",
"@microsoft/sp-core-library": "1.10.0",
"@microsoft/sp-lodash-subset": "1.10.0",
"@microsoft/sp-office-ui-fabric-core": "1.10.0",
"@microsoft/sp-property-pane": "1.10.0",
"@microsoft/sp-webpart-base": "1.10.0",
"@pnp/common": "^1.2.8",
"@pnp/logging": "^1.2.8",
"@pnp/odata": "^1.2.8",
@ -29,6 +30,7 @@
"feedparser": "^2.2.9",
"ical.js": "^1.3.0",
"ics-js": "^0.10.2",
"moment": "^2.24.0",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5",
@ -41,10 +43,11 @@
},
"devDependencies": {
"@microsoft/rush-stack-compiler-2.9": "0.7.16",
"@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-tslint-rules": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.9.1",
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@microsoft/sp-build-web": "1.10.0",
"@microsoft/sp-module-interfaces": "1.10.0",
"@microsoft/sp-tslint-rules": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.10.0",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",

View File

@ -2,54 +2,71 @@ 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";
import { DateBoxSize, IDateBoxProps } from ".";
/**
* 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);
export const DateBox = (props: IDateBoxProps) => {
// convert start and end date into moments so that we can manipulate them
const startMoment: moment.Moment = moment(props.startDate);
// event actually ends one second before the end date
const endMoment: moment.Moment = moment(this.props.endDate).add(-1, "s");
// event actually ends one second before the end date
const endMoment: moment.Moment = moment(props.endDate).add(-1, "s");
// check if both dates are on the same day
const isSameDay: boolean = startMoment.isSame(endMoment, "day");
// 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;
if (isSameDay) {
return (
<div className={css(styles.box,
styles.boxIsSingleDay,
(size === DateBoxSize.Small ? styles.boxIsSmall : styles.boxIsMedium), className)}
data-automation-id="singleDayDayContainer">
(props.size === DateBoxSize.Small ? styles.boxIsSmall : styles.boxIsMedium), props.className)}
style={
props.themeVariant &&
{
// KLUDGE: It seems like the themeVariant palette doesn't expose primaryBackground
backgroundColor: props.themeVariant.palette["primaryBackground"],
borderColor: props.themeVariant.semanticColors.bodyDivider
}}>
<div className={styles.month}
data-automation-id="singleDayMonthContainer">{startMoment.format("MMM").toUpperCase()}</div>
style={
props.themeVariant &&
{ color: props.themeVariant.semanticColors.bodyText }}>{startMoment.format("MMM").toUpperCase()}</div>
<div className={styles.day}
data-automation-id="singleDayDayContainer">{startMoment.format("D")}</div>
style={
props.themeVariant &&
{ color: props.themeVariant.semanticColors.bodyText }}>{startMoment.format("D")}</div>
</div>);
}
private _renderMultiDay(startMoment: moment.Moment, endMoment: moment.Moment): JSX.Element {
const { className, size } = this.props;
} else {
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>
(props.size === DateBoxSize.Small ? styles.boxIsSmall : styles.boxIsMedium), props.className)}
style={
props.themeVariant &&
{
backgroundColor: props.themeVariant.palette["primaryBackground"],
borderColor: props.themeVariant.semanticColors.bodyDivider
}}>
<div className={styles.date} style={
props.themeVariant &&
{ color: props.themeVariant.semanticColors.bodyText }}>{startMoment.format("MMM D").toUpperCase()}</div>
<hr className={styles.separator}
style={
props.themeVariant &&
{
borderColor: props.themeVariant.semanticColors.bodyText
}}
/>
<div className={styles.date} style={
props.themeVariant &&
{ color: props.themeVariant.semanticColors.bodyText }}>{endMoment.format("MMM D").toUpperCase()}</div>
</div>);
}
}
};

View File

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

View File

@ -0,0 +1,4 @@
export enum DateBoxSize {
Small,
Medium
}

View File

@ -0,0 +1,11 @@
import { DateBoxSize } from "./DateBoxSize";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
export interface IDateBoxProps {
startDate: Date;
endDate: Date;
className?: string;
size: DateBoxSize;
themeVariant?: IReadonlyTheme;
}

View File

@ -1,2 +1,3 @@
export * from "./DateBox";
export * from "./DateBox.types";
export * from "./IDateBoxProps";
export * from "./DateBoxSize";

View File

@ -23,7 +23,7 @@
}
.cardWrapper .dateBox {
border-color: $ms-color-neutralTertiaryAlt;
border-color: $ms-color-neutralLight;
}
.normalCard .dateBoxContainer {
@ -44,6 +44,7 @@
.compactCard .dateBox {
margin: auto 14px auto 0;
min-width: 64px;
background-color: $ms-color-white;
}
[dir=rtl] .compactCard .dateBox {
@ -111,8 +112,8 @@
width: 100%;
align-items: center;
-webkit-font-smoothing: antialiased;
background-color: $ms-color-white;
border: 1px solid #eaeaea;
//background-color: $ms-color-white;
border: 1px solid $ms-color-neutralLight;
-webkit-box-sizing: border-box;
box-sizing: border-box;
max-width: 320px;
@ -162,7 +163,7 @@
.category,
.location {
color:$ms-color-neutralSecondary;
color: $ms-color-neutralSecondary;
}
.category,
@ -176,8 +177,3 @@
word-wrap: normal;
height: 18px;
}
// :global(.slick-slide) .cardWrapper {
// padding-left:8px;
// padding-right:8px;
// }

View File

@ -4,172 +4,167 @@ 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 { IEventCardProps } from ".";
import { DateBox, DateBoxSize } from "../DateBox";
import styles from "./EventCard.module.scss";
import { Text } from "@microsoft/sp-core-library";
import { useCallback } from 'react';
/**
* Shows an event in a document card
*/
export class EventCard extends React.Component<IEventCardProps, IEventCardState> {
public render(): React.ReactElement<IEventCardProps> {
const { isNarrow } = this.props;
export const EventCard = (props: IEventCardProps) => {
const { isNarrow, themeVariant, isEditMode, event } = props;
if (isNarrow) {
return this._renderNarrowCell();
} else {
return this._renderNormalCell();
}
// Get the cell information
const { start,
end,
allDay,
title,
url,
category,
location
} = event;
const eventDate: moment.Moment = moment(start);
const dateString: string = allDay ? eventDate.format(strings.AllDayDateFormat) : eventDate.format(strings.LocalizedTimeFormat);
/**
* Handle adding to calendar
*/
const _onAddToMyCalendar = useCallback((): void => {
// 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" });
}
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(strings.AllDayDateFormat) : eventDate.format(strings.LocalizedTimeFormat);
const { isEditMode } = this.props;
return (
// 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()));
}, [event]);
if (isNarrow) {
// Calculate the date and string format
// Define theme variant styles if themevariant was passed
return (
<div>
<div
className={css(styles.cardWrapper, styles.compactCard, styles.root, styles.rootIsCompact)}
style={themeVariant && { backgroundColor: themeVariant.semanticColors.bodyBackground }}
data-is-focusable={true}
data-is-focus-item={true}
role="listitem"
aria-label={Text.format(strings.EventCardWrapperArialLabel, title, dateString)}
>
<DocumentCard
className={css(styles.root, styles.rootIsActionable, styles.rootIsCompact)}
type={DocumentCardType.compact}
style={themeVariant && { backgroundColor: themeVariant.semanticColors.bodyBackground }}
onClickHref={url}
>
<div>
<div
className={css(styles.cardWrapper)}
data-is-focusable={true}
data-is-focus-item={true}
role="listitem"
aria-label={Text.format(strings.EventCardWrapperArialLabel, title, `${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>
<DateBox
className={styles.dateBox}
startDate={start}
endDate={end}
size={DateBoxSize.Small}
themeVariant={themeVariant}
/>
</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(strings.AllDayDateFormat) : eventDate.format(strings.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={Text.format(strings.EventCardWrapperArialLabel, title, 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.title} data-automation-id="event-card-title">{title}</div>
<div className={styles.datetime}>{dateString}</div>
</div>
</DocumentCard>
</div>
<div className={styles.title} style={themeVariant && { color: themeVariant.semanticColors.bodyText }}>{title}</div>
<div className={styles.datetime} style={themeVariant && { color: themeVariant.semanticColors.bodySubtext }}>{dateString}</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()));
}
}
</DocumentCard>
</div>
</div>
);
} else {
return (
<div>
<div
className={css(styles.cardWrapper)}
style={themeVariant && { backgroundColor: themeVariant.semanticColors.bodyBackground }}
data-is-focusable={true}
data-is-focus-item={true}
role="listitem"
aria-label={Text.format(strings.EventCardWrapperArialLabel, title, `${dateString}`)}
tabIndex={0}
>
<DocumentCard
className={css(styles.root, !isEditMode && styles.rootIsActionable, styles.normalCard)}
type={DocumentCardType.normal}
onClickHref={isEditMode ? null : url}
style={themeVariant && { borderColor: themeVariant.semanticColors.bodyDivider }}
>
<FocusZone>
<div className={styles.dateBoxContainer} style={{ height: 160 }}>
<DateBox
className={styles.dateBox}
startDate={start}
endDate={end}
size={DateBoxSize.Medium}
themeVariant={themeVariant}
/>
</div>
<div className={styles.detailsContainer}>
<div className={styles.category} style={themeVariant && { color: themeVariant.semanticColors.bodySubtext }}>{category}</div>
<div className={styles.title} style={themeVariant && { color: themeVariant.semanticColors.bodyText }}>{title}</div>
<div className={styles.datetime} style={themeVariant && { color: themeVariant.semanticColors.bodySubtext }}>{dateString}</div>
<div className={styles.location} style={themeVariant && { color: themeVariant.semanticColors.bodySubtext }}>{location}</div>
<ActionButton
className={styles.addToMyCalendar}
style={themeVariant && { color: themeVariant.semanticColors.bodyText }}
iconProps={{ iconName: "AddEvent" }}
ariaLabel={strings.AddToCalendarAriaLabel}
onClick={_onAddToMyCalendar}
>
{strings.AddToCalendarButtonLabel}
</ActionButton>
</div>
</FocusZone>
</DocumentCard>
</div>
</div>
);
}
};

View File

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

View File

@ -0,0 +1,9 @@
import { ICalendarEvent } from "../../services/CalendarService";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
export interface IEventCardProps {
isEditMode: boolean;
event: ICalendarEvent;
isNarrow: boolean;
themeVariant?: IReadonlyTheme;
}

View File

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

View File

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

View File

@ -0,0 +1,66 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
.Pagination {
width: 100%;
min-width: 240px;
text-align: center;
margin: 0;
padding: 2px 0;
border: 1px solid transparent;
position: relative;
.next,
.prev {
margin: 0;
display: inline-block;
border: none;
}
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;
&:hover, &:active {
color: "[theme:buttonTextCheckedHovered, default: #000]"
}
}
&.noPageNum {
text-align: left;
.next {
float: right;
}
}
}
// .Pagination .next,
// .Pagination .prev {
// margin: 0;
// display: inline-block;
// border: none;
// }
// .Pagination button {
// font-size: 14px;
// font-weight: 400;
// padding: 8px 4px;
// margin: 0 8px;
// cursor: pointer;
// cursor: hand;
// position: relative;
// height: 32px !important;
// display: none;
// outline: 0;
// }

View File

@ -0,0 +1,76 @@
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 { IPaginationProps } from ".";
import styles from "./Pagination.module.scss";
import * as strings from "CalendarFeedSummaryWebPartStrings";
import { useCallback } from 'react';
/**
* A custom pagination control designed to look & feel like Office UI Fabric
*/
export const Pagination = (props: IPaginationProps) => {
const { currentPage, totalItems, itemsCountPerPage } = props;
// calculate the page situation
const numberOfPages: number = Math.round(totalItems / itemsCountPerPage);
// 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;
/**
* Increments the page number unless we're on the last page
*/
const _nextPage = useCallback((): void => {
if (props.currentPage < numberOfPages) {
props.onPageUpdate(props.currentPage + 1);
}
}, [props, numberOfPages]);
/**
* Decrements the page number unless we're on the first page
*/
const _prevPage = useCallback((): void => {
if (props.currentPage > 1) {
props.onPageUpdate(props.currentPage - 1);
}
}, [props]);
return (
<div className={css(styles.Pagination, props.showPageNum ? null : styles.noPageNum)}>
<ActionButton className={styles.prev}
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={_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={_nextPage}
ariaLabel={strings.NextButtonAriaLabel}
>
{strings.NextButtonLabel}
</ActionButton>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./Pagination";
export * from "./IPagingProps";

View File

@ -1,59 +0,0 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.Paging {
width: 100%;
min-width: 240px;
text-align: center;
margin: 0;
padding: 2px 0;
border: 1px solid transparent;
position: relative;
}
.Paging .next,
.Paging .prev {
margin: 0;
display: inline-block;
border: none;
}
.Paging button {
font-size: 14px;
font-weight: 400;
padding: 8px 4px;
margin: 0 8px;
cursor: pointer;
cursor: hand;
position: relative;
height: 32px!important;
display: none;
outline: 0;
}
.Paging.noPageNum {
text-align: left;
}
.noPageNum .next {
float: right;
}
.Paging .next,
.Paging .prev {
margin: 0;
display: inline-block;
border: none;
}
.Paging button {
font-size: 14px;
font-weight: 400;
padding: 8px 4px;
margin: 0 8px;
cursor: pointer;
cursor: hand;
position: relative;
height: 32px!important;
display: none;
outline: 0;
}

View File

@ -1,88 +0,0 @@
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 { currentPage } = this.props;
// calculate the page situation
const numberOfPages: number = this._getNumberOfPages();
// we disable the previous button if we're on page 1
const prevDisabled: boolean = currentPage < 1;
// we disable the next button if we're on the last page
const nextDisabled: boolean = currentPage >= numberOfPages;
return (
<div className={css(styles.Paging, this.props.showPageNum ? null : styles.noPageNum)}>
<ActionButton className={styles.prev}
data-automation-id="previousPage"
onRenderIcon={(props: IButtonProps) => {
// we use the render custom icon method to render the icon consistently with the right icon
return (
<Icon iconName="ChevronLeft" />
);
}}
disabled={prevDisabled}
onClick={this._prevPage}
ariaLabel={strings.PrevButtonAriaLabel}
>
{strings.PrevButtonLabel}
</ActionButton>
{/* NOT IMPLEMENTED: Page numbers aren't shown here, but we'll need them if we want this control to be reusable */}
<ActionButton className={styles.next}
data-automation-id="nextPage"
disabled={nextDisabled}
onRenderMenuIcon={(props: IButtonProps) => {
// we use the render custom menu icon method to render the icon to the right of the text
return (
<Icon iconName="ChevronRight" />
);
}}
onClick={this._nextPage}
ariaLabel={strings.NextButtonAriaLabel}
>
{strings.NextButtonLabel}
</ActionButton>
</div>
);
}
/**
* Increments the page number unless we're on the last page
*/
private _nextPage = (): void => {
const numberOfPages: number = this._getNumberOfPages();
if (this.props.currentPage < numberOfPages) {
this.props.onPageUpdate(this.props.currentPage + 1);
}
}
/**
* Decrements the page number unless we're on the first page
*/
private _prevPage = (): void => {
if (this.props.currentPage > 1) {
this.props.onPageUpdate(this.props.currentPage - 1);
}
}
/**
* Calculates how many pages there will be
*/
private _getNumberOfPages(): number {
const { totalItems, itemsCountPerPage } = this.props;
let numPages: number = Math.round(totalItems / itemsCountPerPage);
return numPages;
}
}

View File

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

View File

@ -1,5 +1,4 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
@import "~office-ui-fabric-react/dist/sass/References.scss";
:export {
centerPadding: 50px;
}
@ -8,7 +7,9 @@
position: relative;
&.filmStrip {
margin: 0 -10px;
margin-bottom: 27px;
margin-left: -10px;
margin-right: -10px;
:global(.slick-slide) {
box-sizing: border-box;
@ -20,14 +21,6 @@
opacity: 0;
}
.sliderButtonRight {
right: 10px;
}
.sliderButtonLeft {
left: 10px;
}
&:hover .sliderButtons {
opacity: 1;
@ -45,8 +38,8 @@
}
.indexButton {
font-size: 17px;
font-weight: 300;
font-size: 28px;
font-weight: 400;
height: 40px;
padding: 0;
border: 0;
@ -67,6 +60,8 @@
&:hover {
color: $ms-color-white;
background-color: $ms-color-black;
opacity: 0.6;
}
&:active {
@ -74,6 +69,39 @@
}
}
.carouselDotsContainer {
.carouselDot {
display: inline-block;
background-color: $ms-color-black;
height: 4px;
width: 4px;
opacity: 0.5;
border: 2px solid $ms-color-black;
border-radius: 4px;
cursor: pointer;
opacity: 0.25;
outline: 0;
&:hover {
opacity: 1;
}
}
}
:global(.slick-active) {
.carouselDotsContainer {
.carouselDot {
background-color: "[theme:themeDark, default: #005a9e]";
opacity: 0.75;
border-color: "[theme:themeDark, default: #005a9e]";
&:hover {
opacity: 1;
}
}
}
}
.indexButton:global(.ms-Button-flexContainer):hover:global(.ms-Icon),
.indexButton:global(.ms-Icon:hover),
.indexButton:hover:global(.ms-Icon) {

View File

@ -1,114 +1,117 @@
import { css } from '@uifabric/utilities/lib/css';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import * as React from 'react';
// import * as slick from 'slick-carousel';
import Slider from 'react-slick';
import { IFilmstripLayoutProps, IFilmstripLayoutState } from "./FilmstripLayout.types";
import { SPComponentLoader } from '@microsoft/sp-loader';
import styles from "./FilmstripLayout.module.scss";
import { useRef } from 'react';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
function useBreakpoints(currentWidth: number, breakpoints: number[]) {
return breakpoints.map(breakpoint => currentWidth < breakpoint);
}
/**
* Filmstrip layout
* Presents the child compoments as a slick slide
*/
export class FilmstripLayout extends React.Component<
IFilmstripLayoutProps,
IFilmstripLayoutState
> {
// the slick slider used in normal views
private _slider: Slider;
export const FilmstripLayout = (props: { children: any; clientWidth: number; themeVariant?: IReadonlyTheme, ariaLabel?: string; }) => {
SPComponentLoader.loadCss('https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.8.1/slick.min.css');
SPComponentLoader.loadCss('https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.8.1/slick-theme.min.css');
/**
*
*/
constructor(props: IFilmstripLayoutProps) {
super(props);
let topElem: React.MutableRefObject<HTMLDivElement> = useRef<HTMLDivElement>(null);
let _slider: React.MutableRefObject<Slider> = useRef<Slider>(null);
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');
const [isSmall, isMedium] = useBreakpoints(props.clientWidth, [696, 928]);
let numSlides: number = 3;
if (isSmall) {
numSlides = 2;
} else if (isMedium) {
numSlides = 3;
} else {
numSlides = 4;
}
/**
* Renders a slick switch, a slide for each child, and next/previous arrows
*/
public render(): React.ReactElement<IFilmstripLayoutProps> {
// slick seems to have an issue with having "infinite" mode set to true and having less items than the number of slides per page
// set infinite to true only if there are more than 3 children
var isInfinite: boolean = React.Children.count(this.props.children) > 3;
var settings: any = {
accessibility: true,
arrows: false,
autoplaySpeed: 5000,
dots: true,
infinite: isInfinite,
slidesToShow: 4,
slidesToScroll: 4,
speed: 500,
centerPadding: styles.centerPadding,
pauseOnHover: true,
variableWidth: false,
useCSS: true,
rows: 1,
respondTo: "slider",
responsive: [
{
breakpoint: 499,
settings: {
slidesToShow: 1,
slidesToScroll: 1
}
},
{
breakpoint: 731,
settings: {
slidesToShow: 2,
slidesToScroll: 2
}
},
{
breakpoint: 963,
settings: {
slidesToShow: 3,
slidesToScroll: 3
}
},
{
breakpoint: 1028,
settings: {
slidesToShow: 4,
slidesToScroll: 4
}
}
]
};
return (
var isInfinite: boolean = React.Children.count(props.children) > numSlides;
var settings: any = {
accessibility: true,
arrows: false,
autoplaySpeed: 5000,
dots: true,
customPaging: (i: number) => {
return (
<a>
<div role="button" className={styles.carouselDotsContainer} aria-label={`Carousel Dot ${i}`} data-is-focusable={true} tabIndex={0}>
<span className={styles.carouselDot} tabIndex={-1}></span>
</div>
</a>
);
},
infinite: isInfinite,
slidesToShow: numSlides,
slidesToScroll: numSlides,
speed: 500,
centerPadding: styles.centerPadding,
pauseOnHover: true,
variableWidth: false,
useCSS: true,
rows: 1,
respondTo: "slider",
};
return (
<>
{/*
KLUDGE:
This is a cheaty way to inject styles from the theme variant when the component does not support theme variant customizations.
We can do this without too much nastiness because this component is added only once per web part
... but I still wish there was a better way
*/}
{props.themeVariant && <style>{`
.${styles.carouselDot} {
background-color: ${props.themeVariant.palette.black}!important;
border-color: ${props.themeVariant.palette.black}!important;
}
.slick-active .${styles.carouselDot} {
background-color: ${props.themeVariant.palette.themeDark}!important;
border-color: ${props.themeVariant.palette.themeDark}!important;
}
.${styles.filmstripLayout} .ms-DocumentCard--actionable:hover {
border-color: ${props.themeVariant.semanticColors.variantBorderHovered}!important;
}
`}
</style>
}
<div>
<div className={css(styles.filmstripLayout, styles.filmStrip)} aria-label={this.props.ariaLabel}>
<Slider ref={c => (this._slider = c)} {...settings}>
{this.props.children}
<div className={css(styles.filmstripLayout, styles.filmStrip)} aria-label={props.ariaLabel} ref={topElem}>
<Slider ref={_slider} {...settings}>
{props.children}
</Slider>
<div
className={css(styles.indexButtonContainer, styles.sliderButtons, styles.sliderButtonLeft)}
onClick={() => this._slider.slickPrev()}
className={css(styles.indexButtonContainer, styles.sliderButtons)}
style={{ left: "10px" }}
onClick={() => _slider.current.slickPrev()}
>
<IconButton
className={css(styles.indexButton, styles.leftPositioned)}
iconProps={{ iconName: "ChevronLeft" }}
iconProps={{ iconName: "ChevronLeft", styles: { root: { fontSize: '28px', fontWeight: '400' } } }}
/>
</div>
<div
className={css(styles.indexButtonContainer, styles.sliderButtons, styles.sliderButtonRight)}
onClick={() => this._slider.slickNext()}
className={css(styles.indexButtonContainer, styles.sliderButtons)}
style={{ right: "10px" }}
onClick={() => _slider.current.slickNext()}
>
<IconButton
className={css(styles.indexButton, styles.rightPositioned)}
iconProps={{ iconName: "ChevronRight" }}
iconProps={{ iconName: "ChevronRight", styles: { root: { fontSize: '28px', fontWeight: '400' } } }}
/>
</div>
</div>
</div>
);
}
}
</>
);
};

View File

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

View File

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

View File

@ -19,6 +19,16 @@ const sampleEvents: ICalendarEvent[] = [
location: "Barrie, ON",
description: "This is a description"
},
{
title: "This is a UTC event",
start: moment().add(1, "d").utc(false).toDate(),
end: moment().add(1, "d").add(1, "h").utc(false).toDate(),
url: "https://www.contoso.com/news-events/events/1UTC/",
allDay: false,
category: "Meeting",
location: "Barrie, ON",
description: "This is a description for a UTC event"
},
{
title: "This event will be in one week",
start: moment().add(1, "w").toDate(),
@ -151,7 +161,7 @@ export class MockCalendarService extends BaseCalendarService implements ICalenda
return new Promise<ICalendarEvent[]>((resolve: any) => {
setTimeout(() => {
resolve(this.filterEventRange(sampleEvents));
}, 1000);
}, 100);
});
}
}

View File

@ -3,7 +3,7 @@
"id": "acef6c3f-852a-42e3-8a4b-7ea191ae9687",
"alias": "CalendarFeedSummaryWebPart",
"componentType": "WebPart",
"supportsThemeVariants": true,
"version": "*",
"manifestVersion": 2,
"supportedHosts": ["SharePointWebPart"],

View File

@ -34,8 +34,8 @@ import { ICalendarFeedSummaryWebPartProps } from "./CalendarFeedSummaryWebPart.t
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;
// Support for theme variants
import { ThemeProvider, ThemeChangedEventArgs, IReadonlyTheme, ISemanticColors } from '@microsoft/sp-component-base';
/**
* Calendar Feed Summary Web Part
@ -46,6 +46,8 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
// the list of proviers available
private _providerList: any[];
private _themeProvider: ThemeProvider;
private _themeVariant: IReadonlyTheme | undefined;
constructor() {
super();
@ -55,6 +57,14 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
protected onInit(): Promise<void> {
return new Promise<void>((resolve, _reject) => {
// Consume the new ThemeProvider service
this._themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey);
// If it exists, get the theme variant
this._themeVariant = this._themeProvider.tryGetTheme();
// Register a handler to be notified if the theme variant changes
this._themeProvider.themeChangedEvent.add(this, this._handleThemeChangedEvent);
let {
cacheDuration,
@ -90,8 +100,8 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
* Renders the web part
*/
public render(): void {
// see if we need to render a mobile view
const isNarrow: boolean = this.domElement.clientWidth <= MaxMobileWidth;
// We pass the width so that the components can resize
const { clientWidth } = this.domElement;
// display the summary (or the configuration screen)
const element: React.ReactElement<ICalendarFeedSummaryProps> = React.createElement(
@ -101,12 +111,13 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
displayMode: this.displayMode,
context: this.context,
isConfigured: this._isConfigured(),
isNarrow: isNarrow,
maxEvents: this.properties.maxEvents,
provider: this._getDataProvider(),
themeVariant: this._themeVariant,
updateProperty: (value: string) => {
this.properties.title = value;
},
clientWidth: clientWidth
}
);
@ -355,4 +366,14 @@ export default class CalendarFeedSummaryWebPart extends BaseClientSideWebPart<IC
provider.MaxTotal = maxTotal;
return provider;
}
/**
* Update the current theme variant reference and re-render.
*
* @param args The new theme
*/
private _handleThemeChangedEvent(args: ThemeChangedEventArgs): void {
this._themeVariant = args.theme;
this.render();
}
}

View File

@ -9,12 +9,13 @@
}
.webPartChrome {
display: -ms-flexbox;
display: flex;
-ms-flex-align: stretch;
align-items: stretch;
-ms-flex-direction: column;
flex-direction: column;
// display: -ms-flexbox;
// display: flex;
// -ms-flex-align: stretch;
// align-items: stretch;
// -ms-flex-direction: column;
// flex-direction: column;
color: inherit;
}
.headerSmMargin {

View File

@ -1,5 +1,4 @@
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";
@ -7,15 +6,19 @@ import * as moment from "moment";
import { FocusZone, FocusZoneDirection, List, Spinner, css } from "office-ui-fabric-react";
import * as React from "react";
import { EventCard } from "../../../shared/components/EventCard";
import { Paging } from "../../../shared/components/Paging";
import { Pagination } from "../../../shared/components/Pagination";
import { CalendarServiceProviderType, ICalendarEvent, ICalendarService } from "../../../shared/services/CalendarService";
import styles from "./CalendarFeedSummary.module.scss";
import { ICalendarFeedSummaryProps, ICalendarFeedSummaryState, IFeedCache } from "./CalendarFeedSummary.types";
import { FilmstripLayout } from "../../../shared/components/filmstripLayout/index";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
// the key used when caching events
const CacheKey: string = "calendarFeedSummary";
// this is the same width that the SharePoint events web parts use to render as narrow
const MaxMobileWidth: number = 480;
/**
* Displays a feed summary from a given calendar feed provider. Renders a different view for mobile/narrow web parts.
*/
@ -90,6 +93,8 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
isConfigured,
} = this.props;
const { semanticColors }: IReadonlyTheme = this.props.themeVariant;
// if we're not configured, show the placeholder
if (!isConfigured) {
return <Placeholder
@ -104,7 +109,7 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
// put everything together in a nice little calendar view
return (
<div className={css(styles.calendarFeedSummary, styles.webPartChrome)}>
<div className={css(styles.calendarFeedSummary, styles.webPartChrome)} style={{backgroundColor: semanticColors.bodyBackground}}>
<div className={css(styles.webPartHeader, styles.headerSmMargin)}>
<WebPartTitle displayMode={this.props.displayMode}
title={this.props.title}
@ -122,8 +127,9 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
* Render your web part content
*/
private _renderContent(): JSX.Element {
const isNarrow: boolean = this.props.clientWidth < MaxMobileWidth;
const {
isNarrow,
displayMode
} = this.props;
const {
@ -253,14 +259,16 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
>
<List
items={pagedEvents}
onRenderCell={(item, index) => (
onRenderCell={(item, _index) => (
<EventCard
isEditMode={isEditMode}
event={item}
isNarrow={true} />
isNarrow={true}
themeVariant={this.props.themeVariant}
/>
)} />
{usePaging &&
<Paging
<Pagination
showPageNum={false}
currentPage={currentPage}
itemsCountPerPage={maxEvents}
@ -289,13 +297,17 @@ export default class CalendarFeedSummary extends React.Component<ICalendarFeedSu
<div role="application">
<FilmstripLayout
ariaLabel={strings.FilmStripAriaLabel}
clientWidth={this.props.clientWidth}
themeVariant={this.props.themeVariant}
>
{events.map((event: ICalendarEvent, index: number) => {
return (<EventCard
key={`eventCard${index}`}
isEditMode={isEditMode}
event={event}
isNarrow={false} />);
isNarrow={false}
themeVariant={this.props.themeVariant} />
);
})}
</FilmstripLayout>
</div>

View File

@ -9,6 +9,7 @@ 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";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
/**
* The props for the calendar feed summary component
@ -19,9 +20,10 @@ export interface ICalendarFeedSummaryProps {
context: IWebPartContext;
updateProperty: (value: string) => void;
isConfigured: boolean;
isNarrow: boolean;
provider: ICalendarService;
maxEvents: number;
themeVariant: IReadonlyTheme;
clientWidth: number;
}
/**

View File

@ -1,4 +1,5 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"inlineSources": false,
"strictNullChecks": false,