├── .babelrc ├── .github └── ISSUE_TEMPLATE │ ├── 1.Bug_report.md │ └── 2.Feature_request.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── _config.yml ├── docs ├── _config.yml ├── bundle.js └── index.html ├── jest.config.js ├── lib ├── enums.js ├── index.js └── utils.js ├── package-lock.json ├── package.json ├── src ├── docs │ ├── Button.jsx │ ├── CodeSnippet.jsx │ ├── Dropdown.jsx │ ├── Modal.jsx │ ├── index.html │ ├── index.jsx │ └── styles.css └── lib │ ├── enums.js │ ├── index.jsx │ ├── utils.js │ └── utils.test.js ├── webpack.config.js ├── yarn-error.log └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-object-rest-spread", 5 | "@babel/plugin-proposal-class-properties" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report for React Add to Calendar HOC 4 | --- 5 | 6 | # Bug report 7 | 8 | ## Describe the bug 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | ## To Reproduce 13 | 14 | Steps to reproduce the behavior, please provide code snippets or a repository: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | ## Expected behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | ## Screenshots 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | ## System information 27 | - OS: [e.g. macOS, Windows] 28 | - Browser (if applies) [e.g. chrome, safari] 29 | - Version of Library: [e.g. 1.0.1] 30 | 31 | ## Additional context 32 | 33 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2.Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Create a feature request for React Add to Calendar HOC 4 | --- 5 | 6 | # Feature request 7 | 8 | ## Is your feature request related to a problem? Please describe. 9 | A clear and concise description of what you want and what your use case is. 10 | 11 | ## Describe the solution you'd like 12 | A clear and concise description of what you want to happen. 13 | 14 | ## Describe alternatives you've considered 15 | A clear and concise description of any alternative solutions or features you've considered. 16 | 17 | ## Additional context 18 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs 2 | src 3 | .babelrc 4 | webpack.config.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 8 6 | 7 | before_install: 8 | - yarn add codecov.io coveralls 9 | 10 | after_success: 11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 13 | 14 | branches: 15 | only: 16 | - master -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jason Leibowitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/react-add-to-calendar-hoc.svg)](https://badge.fury.io/js/react-add-to-calendar-hoc) 2 | [![Travis](https://img.shields.io/travis/jasonleibowitz/react-add-to-calendar-hoc.svg)](https://travis-ci.org/jasonleibowitz/react-add-to-calendar-hoc) 3 | [![Coverage Status](https://coveralls.io/repos/github/jasonleibowitz/react-add-to-calendar-hoc/badge.svg)](https://coveralls.io/github/jasonleibowitz/react-add-to-calendar-hoc) 4 | 5 | # React Add to Calendar HOC 6 | 7 | ## What is This? 8 | 9 | React Add to Calendar HOC is a very lightweight and flexible higher order component that allows you to add _add to calendar_ functionality to your own components. Use your own button component and either dropdown component or modal component. 10 | 11 | ## Why Should I Use This? 12 | 13 | If you want to add "add to calendar" functionality to your application, this is the lightest-weight and most flexibly library that does this. Other libraries that provide this functionality are over 21x the bundle size and don't give you the flexibility of using your own components. There are also no external dependencies to worry about. 14 | 15 | In your project you most likely already have reusable button components to other styles. Rather than dictating what your Add to Calendar component looks like, you provide your own components for this library to add functionality to. You can style your components with CSS in JS, CSS Modules or anything else. This library doesn't care what you use or dictate changes to how you do things. 16 | 17 | It also doesn't have a bundled date library with it either, giving you the flexibility to use whatever library you want. MomentJS is the most popular, but it's incredibly large. You're in charge of formatting the dateTime string and providing it to this lib. It doesn't care what date lib you use. 18 | 19 | ## Examples 20 | 21 | View examples [here](http://leibowitz.me/react-add-to-calendar-hoc/docs/). 22 | 23 | ## Installation 24 | 25 | Using [npm](https://www.npmjs.com/package/react-add-to-calendar-hoc) 26 | 27 | ``` 28 | npm install react-add-to-calendar-hoc --save 29 | yarn add react-add-to-calendar-hoc 30 | ``` 31 | 32 | Then, using a module bundler that supports either CommonJS or ES2015 modules, such as [webpack](https://github.com/webpack/webpack): 33 | 34 | ``` 35 | // Using an ES6 transpiler like Babel 36 | import AddToCalendarHOC from 'react-add-to-calendar-hoc'; 37 | 38 | // Not using an ES6 transpiler 39 | var AddToCalendarHOC = require('react-add-to-calendar-hoc'); 40 | ``` 41 | 42 | #### Props 43 | 44 | The component takes the following props 45 | 46 | | Prop | Type | Required | Default | Description | 47 | | ------------- | -------------- | -------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 48 | | buttonProps | object | | `{}` | Spread props to the button component you pass in to the HOC | 49 | | buttonText | string | | `Add to Calendar` | Text to render in the button | 50 | | className | string | | `null` | className to use on AddToCalendar container | 51 | | dropdownProps | object | | `{}` | Spread props to the dropdown or modal component you pass in to the HOC | 52 | | event | object | Yes | | Event object we use to create calendar links | 53 | | items | array of enums | | `[GOOGLE, ICAL, OUTLOOK, YAHOO]` | By default AddToCalendar renders all of these calendar links. To render a subset of these or to list them in a different order, provide a list of items using the enum SHARE_SITES, which is a named import from the library | 54 | | linkProps | object | | `{}` | Spread props to the link components we render for each calendar item | 55 | 56 | #### Event Object Shape 57 | 58 | All of these properties are required. 59 | 60 | | Prop | Type | Description | 61 | | ------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 62 | | description | string | Description of event. Put in the notes or body section of event. | 63 | | duration | string or number | Duration of event in hours. If string, must be four digits, e.g. `'0200'` or `'0130'`. If number, must represent hours in decimal form, i.e. `2` or `2.15`. This is only used for Yahoo. | 64 | | endDatetime | string | End date time of event. Must be formatted in `YYYYMMDDTHHmmssZ` format or `YYYYMMDDTHHmmss` if timezone is also provided. Use any date lib you want, but the format must match this. | 65 | | location | string | Location of event. Use an address for best specificity and for Google Maps to populate a Map. Sometimes a location name will also populate a map. | 66 | | startDatetime | string | Start date time of event. Must be formatted in `YYYYMMDDTHHmmssZ` format or `YYYYMMDDTHHmmss` if timezone is also provided. Use any date lib you want, but the format must match this. | 67 | | timezone | string | Valid TZ env variable. See list of [valid options here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). | 68 | | title | string | Name of your event. | 69 | 70 | #### Helper functions 71 | 72 | | Function | Arguments | Description | 73 | | -------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- | 74 | | onRequestClose | event | Handles closing of the modal or dropdown. Can be applied to the Button in the provided Overlay to return to previous view. | 75 | 76 | ## Dependencies 77 | 78 | React Add to Calendar HOC has _zero_ external dependencies. 79 | 80 | ## Edge Cases 81 | 82 | Because this library aims to be extremely unopinionated and lightweight it doesn't handle most edge cases, instead letting you handle them as you'd like. Here are some common edge cases and recommendations for what to do. 83 | 84 | #### Open in Outlook doesn't work on iOS 85 | 86 | The option to download an ICS file to open in Outlook won't work on iOS because iOS devices don't let you choose which app to open certain files in. iOS devices will always open any ics in the default calendar overlay. 87 | 88 | You can handle this case by checking if the user's device is iOS and then customizing the list of items. There's an example in the docs to handle this exact edge case. Look for the section "Handle iPhone Options". 89 | 90 | #### How do I specify an event in a specific timezone 91 | 92 | Instead of passing `startDatetime` and `endDatetime` as UTC values pass in local time formats, i.e. `YYYYMMDDTHHmmss` and also provide a valid `timezone` property. (For a list of valid properties [see the TZ column here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)). 93 | 94 | Doing this will ensure that regardless of the user's local timezone the event created will always be in the correct time for the timezone you specified. It will also include a timezone property in the created event so the user knows what timezone the event is in. 95 | 96 | ## Reporting Issues 97 | 98 | If you believe you've found an issue, please [report it](https://github.com/jasonleibowitz/react-add-to-calendar-hoc/issues) along with any relevant details to reproduce it. 99 | 100 | ## Contributions 101 | 102 | Coming Soon 103 | 104 | ## Compatibility 105 | 106 | 107 | 108 | This component is expected to work on modern browsers, but if it breaks on a particular browser please [file an issue](https://github.com/jasonleibowitz/react-add-to-calendar-hoc/issues/new?template=1.Bug_report.md). We can check the problem using [browserstack](http://browserstack.com) - a great service for cross-browser testing. They also support open source projects like this one. 109 | 110 | ## License 111 | 112 | MIT 113 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | React Add to Calendar HOC 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testURL: 'http://localhost/' 3 | }; -------------------------------------------------------------------------------- /lib/enums.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.SHARE_SITES = void 0; 7 | var SHARE_SITES = { 8 | GOOGLE: 'Google', 9 | ICAL: 'iCal', 10 | OUTLOOK: 'Outlook', 11 | YAHOO: 'Yahoo' 12 | }; 13 | exports.SHARE_SITES = SHARE_SITES; -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = AddToCalendar; 7 | Object.defineProperty(exports, "SHARE_SITES", { 8 | enumerable: true, 9 | get: function get() { 10 | return _enums.SHARE_SITES; 11 | } 12 | }); 13 | 14 | var _react = _interopRequireWildcard(require("react")); 15 | 16 | var _propTypes = _interopRequireDefault(require("prop-types")); 17 | 18 | var _enums = require("./enums"); 19 | 20 | var _utils = require("./utils"); 21 | 22 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 23 | 24 | function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = Object.defineProperty && Object.getOwnPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : {}; if (desc.get || desc.set) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } } newObj.default = obj; return newObj; } } 25 | 26 | function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } 27 | 28 | function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } 29 | 30 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 31 | 32 | function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } 33 | 34 | function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } 35 | 36 | function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } 37 | 38 | function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } 39 | 40 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } 41 | 42 | function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } 43 | 44 | function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } 45 | 46 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 47 | 48 | function AddToCalendar(WrappedButton, WrappedDropdown) { 49 | var _class, _temp2; 50 | 51 | return _temp2 = _class = 52 | /*#__PURE__*/ 53 | function (_Component) { 54 | _inherits(AddToCalendarWrapped, _Component); 55 | 56 | function AddToCalendarWrapped() { 57 | var _getPrototypeOf2; 58 | 59 | var _temp, _this; 60 | 61 | _classCallCheck(this, AddToCalendarWrapped); 62 | 63 | for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { 64 | args[_key] = arguments[_key]; 65 | } 66 | 67 | return _possibleConstructorReturn(_this, (_temp = _this = _possibleConstructorReturn(this, (_getPrototypeOf2 = _getPrototypeOf(AddToCalendarWrapped)).call.apply(_getPrototypeOf2, [this].concat(args))), _defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "state", { 68 | dropdownOpen: false 69 | }), _defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "handleCalendarButtonClick", function (e) { 70 | var filename = _this.props.filename; 71 | e.preventDefault(); 72 | var url = e.currentTarget.getAttribute('href'); 73 | 74 | if (url.startsWith('BEGIN')) { 75 | var blob = new Blob([url], { 76 | type: 'text/calendar;charset=utf-8' 77 | }); 78 | 79 | if ((0, _utils.isInternetExplorer)()) { 80 | window.navigator.msSaveOrOpenBlob(blob, "".concat(filename, ".ics")); 81 | } else { 82 | var link = document.createElement('a'); 83 | link.href = window.URL.createObjectURL(blob); 84 | link.setAttribute('download', "".concat(filename, ".ics")); 85 | document.body.appendChild(link); 86 | link.click(); 87 | document.body.removeChild(link); 88 | } 89 | } else { 90 | window.open(url, '_blank'); 91 | } 92 | }), _defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "handleDropdownToggle", function (e) { 93 | e.preventDefault(); 94 | 95 | _this.setState(function (prevState) { 96 | return { 97 | dropdownOpen: !prevState.dropdownOpen 98 | }; 99 | }); 100 | }), _temp)); 101 | } 102 | 103 | _createClass(AddToCalendarWrapped, [{ 104 | key: "render", 105 | value: function render() { 106 | var _this2 = this; 107 | 108 | var _this$props = this.props, 109 | buttonProps = _this$props.buttonProps, 110 | buttonText = _this$props.buttonText, 111 | className = _this$props.className, 112 | dropdownProps = _this$props.dropdownProps, 113 | event = _this$props.event, 114 | items = _this$props.items, 115 | linkProps = _this$props.linkProps; 116 | return _react.default.createElement("div", { 117 | className: className 118 | }, _react.default.createElement(WrappedButton, _extends({}, buttonProps, { 119 | onClick: this.handleDropdownToggle 120 | }), buttonText), this.state.dropdownOpen && _react.default.createElement(WrappedDropdown, _extends({}, dropdownProps, { 121 | isOpen: this.state.dropdownOpen, 122 | onRequestClose: this.handleDropdownToggle 123 | }), items.map(function (item) { 124 | return _react.default.createElement("a", _extends({}, linkProps, { 125 | key: item, 126 | onClick: _this2.handleCalendarButtonClick, 127 | href: (0, _utils.buildShareUrl)(event, item) 128 | }), item); 129 | }))); 130 | } 131 | }]); 132 | 133 | return AddToCalendarWrapped; 134 | }(_react.Component), _defineProperty(_class, "propTypes", { 135 | buttonProps: _propTypes.default.shape(), 136 | buttonText: _propTypes.default.node, 137 | className: _propTypes.default.string, 138 | dropdownProps: _propTypes.default.shape(), 139 | event: _propTypes.default.shape({ 140 | description: _propTypes.default.string, 141 | duration: _propTypes.default.oneOfType([_propTypes.default.number, _propTypes.default.string]).isRequired, 142 | endDatetime: _propTypes.default.string.isRequired, 143 | location: _propTypes.default.string, 144 | startDatetime: _propTypes.default.string.isRequired, 145 | title: _propTypes.default.string 146 | }).isRequired, 147 | filename: _propTypes.default.string, 148 | items: _propTypes.default.arrayOf(_propTypes.default.oneOf(Object.keys(_enums.SHARE_SITES).map(function (itm) { 149 | return _enums.SHARE_SITES[itm]; 150 | }))), 151 | linkProps: _propTypes.default.shape() 152 | }), _defineProperty(_class, "defaultProps", { 153 | buttonProps: {}, 154 | buttonText: 'Add to Calendar', 155 | className: null, 156 | dropdownProps: {}, 157 | filename: 'download', 158 | items: Object.keys(_enums.SHARE_SITES).map(function (itm) { 159 | return _enums.SHARE_SITES[itm]; 160 | }), 161 | linkProps: {} 162 | }), _temp2; 163 | } -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.buildShareUrl = exports.escapeICSDescription = exports.isInternetExplorer = exports.isMobile = exports.formatDuration = exports.formatDate = void 0; 7 | 8 | var _enums = require("./enums"); 9 | 10 | /** 11 | * Converts Date String with UTC timezone to date consumable by calendar 12 | * apps. Changes +00:00 to Z. 13 | * @param {string} Date in YYYYMMDDTHHmmssZ format 14 | * @returns {string} Date with +00:00 replaceed with Z 15 | */ 16 | var formatDate = function formatDate(date) { 17 | return date && date.replace('+00:00', 'Z'); 18 | }; 19 | 20 | exports.formatDate = formatDate; 21 | 22 | var formatDuration = function formatDuration(duration) { 23 | if (typeof duration === 'string') return duration; 24 | var parts = duration.toString().split('.'); 25 | 26 | if (parts.length < 2) { 27 | parts.push('00'); 28 | } 29 | 30 | return parts.map(function (part) { 31 | return part.length === 2 ? part : "0".concat(part); 32 | }).join(''); 33 | }; 34 | /** 35 | * Tests provided UserAgent against Known Mobile User Agents 36 | * @returns {bool} isMobileDevice 37 | */ 38 | 39 | 40 | exports.formatDuration = formatDuration; 41 | 42 | var isMobile = function isMobile() { 43 | return /Mobile|iP(hone|od|ad)|Android|BlackBerry|IEMobile/.test(window.navigator.userAgent || window.navigator.vendor || window.opera); 44 | }; 45 | /** 46 | * Tests userAgent to see if browser is IE 47 | * @returns {bool} isInternetExplorer 48 | */ 49 | 50 | 51 | exports.isMobile = isMobile; 52 | 53 | var isInternetExplorer = function isInternetExplorer() { 54 | return /MSIE/.test(window.navigator.userAgent) || /Trident/.test(window.navigator.userAgent); 55 | }; 56 | 57 | exports.isInternetExplorer = isInternetExplorer; 58 | 59 | var escapeICSDescription = function escapeICSDescription(description) { 60 | return description.replace(/(\r?\n|
)/g, '\\n'); 61 | }; 62 | /** 63 | * Takes an event object and returns a Google Calendar Event URL 64 | * @param {string} event.description 65 | * @param {string} event.endDatetime 66 | * @param {string} event.location 67 | * @param {string} event.startDatetime 68 | * @param {string} event.title 69 | * @returns {string} Google Calendar Event URL 70 | */ 71 | 72 | 73 | exports.escapeICSDescription = escapeICSDescription; 74 | 75 | var googleShareUrl = function googleShareUrl(_ref) { 76 | var description = _ref.description, 77 | endDatetime = _ref.endDatetime, 78 | location = _ref.location, 79 | startDatetime = _ref.startDatetime, 80 | timezone = _ref.timezone, 81 | title = _ref.title; 82 | return "https://calendar.google.com/calendar/render?action=TEMPLATE&dates=".concat(startDatetime, "/").concat(endDatetime).concat(timezone && "&ctz=".concat(timezone), "&location=").concat(location, "&text=").concat(title, "&details=").concat(description); 83 | }; 84 | /** 85 | * Takes an event object and returns a Yahoo Calendar Event URL 86 | * @param {string} event.description 87 | * @param {string} event.duration 88 | * @param {string} event.location 89 | * @param {string} event.startDatetime 90 | * @param {string} event.title 91 | * @returns {string} Yahoo Calendar Event URL 92 | */ 93 | 94 | 95 | var yahooShareUrl = function yahooShareUrl(_ref2) { 96 | var description = _ref2.description, 97 | duration = _ref2.duration, 98 | location = _ref2.location, 99 | startDatetime = _ref2.startDatetime, 100 | title = _ref2.title; 101 | return "https://calendar.yahoo.com/?v=60&view=d&type=20&title=".concat(title, "&st=").concat(startDatetime, "&dur=").concat(duration, "&desc=").concat(description, "&in_loc=").concat(location); 102 | }; 103 | /** 104 | * Takes an event object and returns an array to be downloaded as ics file 105 | * @param {string} event.description 106 | * @param {string} event.endDatetime 107 | * @param {string} event.location 108 | * @param {string} event.startDatetime 109 | * @param {string} event.title 110 | * @returns {array} ICS Content 111 | */ 112 | 113 | 114 | var buildShareFile = function buildShareFile(_ref3) { 115 | var _ref3$description = _ref3.description, 116 | description = _ref3$description === void 0 ? '' : _ref3$description, 117 | _ref3$ctz = _ref3.ctz, 118 | ctz = _ref3$ctz === void 0 ? '' : _ref3$ctz, 119 | endDatetime = _ref3.endDatetime, 120 | _ref3$location = _ref3.location, 121 | location = _ref3$location === void 0 ? '' : _ref3$location, 122 | startDatetime = _ref3.startDatetime, 123 | _ref3$timezone = _ref3.timezone, 124 | timezone = _ref3$timezone === void 0 ? '' : _ref3$timezone, 125 | _ref3$title = _ref3.title, 126 | title = _ref3$title === void 0 ? '' : _ref3$title; 127 | var content = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', "URL:".concat(document.URL), 'METHOD:PUBLISH', // TODO: Will need to parse the date without Z for ics 128 | // This means I'll probably have to require a date lib - luxon most likely or datefns 129 | timezone === '' ? "DTSTART:".concat(startDatetime) : "DTSTART;TZID=".concat(timezone, ":").concat(startDatetime), timezone === '' ? "DTEND:".concat(endDatetime) : "DTEND;TZID=".concat(timezone, ":").concat(endDatetime), "SUMMARY:".concat(title), "DESCRIPTION:".concat(escapeICSDescription(description)), "LOCATION:".concat(location), 'END:VEVENT', 'END:VCALENDAR'].join('\n'); 130 | return isMobile() ? encodeURI("data:text/calendar;charset=utf8,".concat(content)) : content; 131 | }; 132 | /** 133 | * Takes an event object and a type of URL and returns either a calendar event 134 | * URL or the contents of an ics file. 135 | * @param {string} event.description 136 | * @param {string} event.duration 137 | * @param {string} event.endDatetime 138 | * @param {string} event.location 139 | * @param {string} event.startDatetime 140 | * @param {string} event.title 141 | * @param {enum} type One of SHARE_SITES from ./enums 142 | */ 143 | 144 | 145 | var buildShareUrl = function buildShareUrl(_ref4, type) { 146 | var _ref4$description = _ref4.description, 147 | description = _ref4$description === void 0 ? '' : _ref4$description, 148 | duration = _ref4.duration, 149 | endDatetime = _ref4.endDatetime, 150 | _ref4$location = _ref4.location, 151 | location = _ref4$location === void 0 ? '' : _ref4$location, 152 | startDatetime = _ref4.startDatetime, 153 | _ref4$timezone = _ref4.timezone, 154 | timezone = _ref4$timezone === void 0 ? '' : _ref4$timezone, 155 | _ref4$title = _ref4.title, 156 | title = _ref4$title === void 0 ? '' : _ref4$title; 157 | var encodeURI = type !== _enums.SHARE_SITES.ICAL && type !== _enums.SHARE_SITES.OUTLOOK; 158 | var data = { 159 | description: encodeURI ? encodeURIComponent(description) : description, 160 | duration: formatDuration(duration), 161 | endDatetime: formatDate(endDatetime), 162 | location: encodeURI ? encodeURIComponent(location) : location, 163 | startDatetime: formatDate(startDatetime), 164 | timezone: timezone, 165 | title: encodeURI ? encodeURIComponent(title) : title 166 | }; 167 | 168 | switch (type) { 169 | case _enums.SHARE_SITES.GOOGLE: 170 | return googleShareUrl(data); 171 | 172 | case _enums.SHARE_SITES.YAHOO: 173 | return yahooShareUrl(data); 174 | 175 | default: 176 | return buildShareFile(data); 177 | } 178 | }; 179 | 180 | exports.buildShareUrl = buildShareUrl; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-add-to-calendar-hoc", 3 | "version": "1.0.10", 4 | "description": "Simple Unopinionated React Add to Calendar Button. Bring your own components.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "dev": "concurrently \"npm run lib:watch\" \"npm run docs\"", 8 | "lib": "babel src/lib -d lib --ignore 'src/lib/**/*.test.js'", 9 | "lib:watch": "babel src/lib -w -d lib --ignore 'src/lib/**/*.test.js'", 10 | "docs": "webpack-dev-server --mode development", 11 | "docs:prod": "webpack --mode production", 12 | "test": "jest --coverage" 13 | }, 14 | "keywords": [], 15 | "license": "MIT", 16 | "peerDependencies": { 17 | "react": "^15.3.0 || ^16.2.0", 18 | "react-dom": "^15.3.0 || ^16.2.0" 19 | }, 20 | "dependencies": { 21 | "core-js": "^2.5.7", 22 | "prop-types": "^15.6.2" 23 | }, 24 | "devDependencies": { 25 | "@babel/cli": "^7.0.0-beta.46", 26 | "@babel/core": "^7.0.0-beta.49", 27 | "@babel/plugin-proposal-class-properties": "^7.0.0-beta.46", 28 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.46", 29 | "@babel/preset-env": "^7.0.0-beta.46", 30 | "@babel/preset-react": "^7.0.0-beta.46", 31 | "babel-core": "^7.0.0-0", 32 | "babel-jest": "^23.0.1", 33 | "babel-loader": "^8.0.0-beta.0", 34 | "babel-preset-env": "^1.7.0", 35 | "babel-preset-react": "^6.24.1", 36 | "concurrently": "^3.5.1", 37 | "coveralls": "^3.0.1", 38 | "css-loader": "^0.28.11", 39 | "emotion": "^9.1.3", 40 | "html-webpack-plugin": "^3.2.0", 41 | "jest": "^23.1.0", 42 | "luxon": "^1.4.4", 43 | "moment-timezone": "^0.5.21", 44 | "react": "^16.3.2", 45 | "react-dom": "^16.3.2", 46 | "react-highlight": "^0.12.0", 47 | "react-modal": "^3.4.5", 48 | "react-syntax-highlighter": "^7.0.4", 49 | "react-test-renderer": "^16.4.0", 50 | "style-loader": "^0.21.0", 51 | "webpack": "^4.6.0", 52 | "webpack-cli": "^2.0.15", 53 | "webpack-dev-server": "^3.1.3" 54 | }, 55 | "author": "Jason Leibowitz ", 56 | "homepage": "http://leibowitz.me/react-add-to-calendar-hoc/", 57 | "repository": { 58 | "type": "git", 59 | "url": "git@github.com:jasonleibowitz/react-add-to-calendar-hoc.git" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/docs/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | const buttonClassName = css` 5 | border: 1px solid #E5E5E5; 6 | color: #E42D2D; 7 | padding: 10px 20px; 8 | font-size: 16px; 9 | font-weight: 700; 10 | border-radius: 3px; 11 | width: 300px; 12 | height: 50px; 13 | cursor: pointer; 14 | text-transform: uppercase; 15 | 16 | &:focus { 17 | outline: none; 18 | } 19 | `; 20 | 21 | export default function Button({ children, onClick }) { 22 | return ( 23 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/docs/CodeSnippet.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import SyntaxHighlighter, { registerLanguage } from "react-syntax-highlighter/prism-light"; 4 | import jsx from 'react-syntax-highlighter/languages/prism/jsx'; 5 | import atomDark from 'react-syntax-highlighter/styles/prism/atom-dark'; 6 | import { css } from 'emotion'; 7 | 8 | registerLanguage('jsx', jsx); 9 | 10 | const styles = css` 11 | width: 100%; 12 | max-width: 900px; 13 | margin: 0 auto !important; 14 | font-size: 12px; 15 | 16 | @media (min-width: 900px) { 17 | font-size: 16px; 18 | } 19 | `; 20 | 21 | export default function CodeSnippet({ children }) { 22 | return ( 23 | 28 | {children} 29 | 30 | ) 31 | } -------------------------------------------------------------------------------- /src/docs/Dropdown.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | const dropdownStyles = css` 5 | padding: 10px; 6 | border: 1px solid #E5E5E5; 7 | border-top: none; 8 | width: 300px; 9 | background-color: #FFF; 10 | margin: 0 auto; 11 | `; 12 | 13 | export default function Dropdown({ children }) { 14 | return ( 15 |
16 | {children} 17 |
18 | ); 19 | } -------------------------------------------------------------------------------- /src/docs/Modal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from './Button'; 3 | import Modal from 'react-modal'; 4 | import { css } from 'emotion'; 5 | 6 | const containerStyles = css` 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | justify-content: space-between; 11 | width: 450px; 12 | height: 375px; 13 | margin: 0 auto; 14 | top: 50%; 15 | left: 0; 16 | right: 0; 17 | transform: translateY(-50%); 18 | position: absolute; 19 | box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.3); 20 | padding: 30px; 21 | background-color: #FFF; 22 | 23 | &:focus { 24 | outline: none; 25 | } 26 | `; 27 | 28 | export default function CalendarModal({ 29 | children, 30 | isOpen, 31 | onRequestClose, 32 | }) { 33 | return ( 34 | 40 |

Add to Calendar

41 |
{children}
42 | 43 |
44 | ); 45 | } -------------------------------------------------------------------------------- /src/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | React Add to Calendar HOC 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/docs/index.jsx: -------------------------------------------------------------------------------- 1 | import 'core-js/es6'; 2 | import 'core-js/es7'; 3 | 4 | import React from "react"; 5 | import { render } from "react-dom"; 6 | import AddToCalendarHOC, { SHARE_SITES } from "../../lib"; 7 | import Button from './Button'; 8 | import CalendarModal from './Modal'; 9 | import CodeSnippet from './CodeSnippet'; 10 | import Dropdown from './Dropdown'; 11 | import { DateTime } from 'luxon'; 12 | import moment from 'moment-timezone'; 13 | import { css } from 'emotion'; 14 | import "./styles.css"; 15 | 16 | const pageStyles = css` 17 | width: 100%; 18 | padding: 0 20px; 19 | margin: 0 auto; 20 | 21 | @media (min-width: 768px) { 22 | width: 75%; 23 | padding: 0; 24 | } 25 | `; 26 | 27 | const componentStyles = css` 28 | width: 100%; 29 | margin: 0 auto; 30 | text-align: center; 31 | padding: 0 0 30px; 32 | 33 | @media (min-width: 768px) { 34 | width: 50%; 35 | } 36 | `; 37 | 38 | const linkStyles = css` 39 | text-decoration: none; 40 | display: block; 41 | color: #E42D2D; 42 | font-size: 18px; 43 | text-align: center; 44 | padding: 6px; 45 | `; 46 | 47 | const titleStyles = css` 48 | margin: 75px 0; 49 | text-align: center; 50 | `; 51 | 52 | const highlightText = css` 53 | font-family: Courier; 54 | font-style: italic; 55 | color: rgb(218, 49, 80); 56 | background-color: #FFF; 57 | padding: 1px 4px; 58 | `; 59 | 60 | const subTitleStyles = css` 61 | margin: 50px 0; 62 | text-align: center; 63 | `; 64 | 65 | const paragraphStyles = css` 66 | margin: 30px auto; 67 | width: 80%; 68 | `; 69 | 70 | const startDatetime = moment().utc().add(2, 'days'); 71 | const endDatetime = startDatetime.clone().add(2, 'hours'); 72 | const duration = endDatetime.diff(startDatetime, 'hours'); 73 | const event = { 74 | description: 'Description of event. Going to have a lot of fun doing things that we scheduled ahead of time.', 75 | duration, 76 | endDatetime: endDatetime.format('YYYYMMDDTHHmmssZ'), 77 | location: 'NYC', 78 | startDatetime: startDatetime.format('YYYYMMDDTHHmmssZ'), 79 | title: 'Super Fun Event', 80 | } 81 | 82 | const eventInDifferentTimezone = { 83 | ...event, 84 | location: 'London', 85 | endDatetime: moment().tz('Europe/London').add(2, 'days').add(2, 'hours').format('YYYYMMDDTHHmmss'), 86 | startDatetime: moment().tz('Europe/London').add(2, 'days').format('YYYYMMDDTHHmmss'), 87 | timezone: 'Europe/London', 88 | } 89 | 90 | const luxonStart = DateTime.fromObject({ year: 2018, month: 10, day: 24, hour: 12, minute: 15, zone: 'America/New_York' }); 91 | const luxonEnd = DateTime.fromObject({ year: 2018, month: 10, day: 24, hour: 14, minute: 15, zone: 'America/New_York' }); 92 | const luxonEvent = { 93 | ...event, 94 | startDatetime: `${luxonStart.toFormat('yyyyLLdd')}T${luxonStart.toFormat('HHmmss')}`, 95 | endDatetime: `${luxonEnd.toFormat('yyyyLLdd')}T${luxonEnd.toFormat('HHmmss')}`, 96 | location: 'NYC', 97 | timezone: 'America/New_York', 98 | } 99 | 100 | const AddToCalendarDropdown = AddToCalendarHOC(Button, Dropdown); 101 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 102 | const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 103 | 104 | function Demo() { 105 | return ( 106 |
107 |

108 | Examples of React Add to Calendar HOC 109 |

110 |
111 |

Event passed into examples

112 | 113 | {` 114 | const startDatetime = moment().utc().add(2, 'days'); 115 | const endDatetime = startDatetime.clone().add(2, 'hours'); 116 | const duration = moment.duration(endDatetime.diff(startDatetime)).asHours(); 117 | const event = { 118 | description: 'Description of event. Going to have a lot of fun doing things that we scheduled ahead of time.', 119 | duration, 120 | endDatetime: endDatetime.format('YYYYMMDDTHHmmssZ'), 121 | location: 'NYC', 122 | startDatetime: startDatetime.format('YYYYMMDDTHHmmssZ'), 123 | title: 'Super Fun Event', 124 | } 125 | `} 126 | 127 |
128 |

Dropdown Example

129 | 136 | 137 | {` 138 | const AddToCalendarDropdown = AddToCalendarHOC(Button, Dropdown); 139 | ... 140 | 147 | `} 148 | 149 | 150 |

Dropdown Example - Handle Newlines in Description

151 | Going to have a lot of fun doing things that we scheduled ahead of time.' 159 | }} 160 | /> 161 | 162 | {` 163 | const AddToCalendarDropdown = AddToCalendarHOC(Button, Dropdown); 164 | ... 165 | 172 | `} 173 | 174 | 175 |

Modal Example

176 | 183 | 184 | {` 185 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 186 | ... 187 | 194 | `} 195 | 196 | 197 |

Custom Button Text

198 | 206 | 207 | {` 208 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 209 | ... 210 | 218 | `} 219 | 220 | 221 |

Customized Item List Example

222 | 230 | 231 | {` 232 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 233 | ... 234 | 242 | `} 243 | 244 | 245 |

Custom Download Filename

246 | 254 | 255 | {` 256 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 257 | ... 258 | 266 | `} 267 | 268 | 269 |

Handle iPhone Options

270 |

iPhones don't allow users to select which app they want to open ics files with, so there is no reason to offer both iCal and Outlook options for users on iOS devices. This example shows how to conditionally change which items to display based on the user's device.

271 | 279 | 280 | {` 281 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 282 | const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 283 | ... 284 | 292 | `} 293 | 294 | 295 |

Handle Timezones

296 |

To support events in a specific timezone you have to do a couple of things. First, pass in an additional property, timezone. The value of this should be a valid TZ environment variable (See here for a list).

297 |

You should also pass the value of startDatetime and endDatetime as local values and not UTC. In other words, if you want to create an event at 11am EST you should pass in a time value of 11am, not 7am UTC (EST has -04:00 offset). You can do this by formatting the date as YYYYMMDDTHHmmss - without the Z property.

298 |

Doing this will result in two things -- regardless of the timezone of your users, the event will always be created at the correct time for the timezone set. Secondly, the event will now include timezone information, i.e. it will say Eastern Time.

299 | 306 | 307 | {` 308 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 309 | const eventInDifferentTimezone = { 310 | ...event, 311 | location: 'London', 312 | endDatetime: moment().tz('Europe/London').add(2, 'days').add(2, 'hours').format('YYYYMMDDTHHmmss'), 313 | startDatetime: moment().tz('Europe/London').add(2, 'days').format('YYYYMMDDTHHmmss'), 314 | timezone: 'Europe/London', 315 | } 316 | ... 317 | 324 | `} 325 | 326 | 327 |

Use Moment Alternative

328 |

Moment is known to be a MASSIVE library. v2.22.2 is 64.2kb minified + gzipped and moment-timezone v0.5.21 is 89.8kb minified + gzipped. There are plenty of other date time libraries for JS that are way smaller. Using one of these helps you avoid overly bloating your application and sending too many vendor files to the client. One great option is Luxon. Luxon v.1.4.4 is 16.9kb minified + gzipped.

329 |

This example shows how to use the Luxon library (instead of Moment) to construct startDatetime and endDatetime

330 | 337 | 338 | {` 339 | const AddToCalendarModal = AddToCalendarHOC(Button, CalendarModal); 340 | const startTime = DateTime.fromObject({ year: 2018, month: 10, day: 25, hour: 12 }); 341 | const endTime = startTime.plus({ hours: 2 }); 342 | const duration = endDatetime.diff(startDatetime).as('hours'); 343 | const eventInDifferentTimezone = { 344 | ...event, 345 | description: 'Description of event. Going to have a lot of fun doing things that we scheduled ahead of time.', 346 | duration, 347 | endDatetime: endTime.toFormat('YYYYMMDDTHHmmss'), 348 | location: 'London', 349 | startDatetime: startTime.toFormat('YYYYMMDDTHHmmss'), 350 | timezone: 'Europe/London', 351 | title: 'Super Fun Event', 352 | } 353 | ... 354 | 361 | `} 362 | 363 |
364 | ); 365 | } 366 | 367 | render(, document.getElementById("app")); 368 | -------------------------------------------------------------------------------- /src/docs/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background: #eee; 7 | font-family: "Tahoma", Arial, sans-serif; 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/enums.js: -------------------------------------------------------------------------------- 1 | export const SHARE_SITES = { 2 | GOOGLE: 'Google', 3 | ICAL: 'iCal', 4 | OUTLOOK: 'Outlook', 5 | YAHOO: 'Yahoo', 6 | }; -------------------------------------------------------------------------------- /src/lib/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { SHARE_SITES } from './enums'; 4 | import { buildShareUrl, isInternetExplorer } from './utils'; 5 | 6 | export { SHARE_SITES }; 7 | export default function AddToCalendar(WrappedButton, WrappedDropdown) { 8 | return class AddToCalendarWrapped extends Component { 9 | static propTypes = { 10 | buttonProps: PropTypes.shape(), 11 | buttonText: PropTypes.node, 12 | className: PropTypes.string, 13 | dropdownProps: PropTypes.shape(), 14 | event: PropTypes.shape({ 15 | description: PropTypes.string, 16 | duration: PropTypes.oneOfType([ 17 | PropTypes.number, 18 | PropTypes.string, 19 | ]).isRequired, 20 | endDatetime: PropTypes.string.isRequired, 21 | location: PropTypes.string, 22 | startDatetime: PropTypes.string.isRequired, 23 | title: PropTypes.string, 24 | }).isRequired, 25 | filename: PropTypes.string, 26 | items: PropTypes.arrayOf( 27 | PropTypes.oneOf( 28 | Object.keys(SHARE_SITES).map(itm => SHARE_SITES[itm]) 29 | ) 30 | ), 31 | linkProps: PropTypes.shape(), 32 | }; 33 | 34 | static defaultProps = { 35 | buttonProps: {}, 36 | buttonText: 'Add to Calendar', 37 | className: null, 38 | dropdownProps: {}, 39 | filename: 'download', 40 | items: Object.keys(SHARE_SITES).map(itm => SHARE_SITES[itm]), 41 | linkProps: {}, 42 | }; 43 | 44 | state = { 45 | dropdownOpen: false, 46 | }; 47 | 48 | handleCalendarButtonClick = e => { 49 | const { filename } = this.props; 50 | e.preventDefault(); 51 | const url = e.currentTarget.getAttribute('href'); 52 | if (url.startsWith('BEGIN')) { 53 | const blob = new Blob([url], { type: 'text/calendar;charset=utf-8' }); 54 | 55 | if (isInternetExplorer()) { 56 | window.navigator.msSaveOrOpenBlob(blob, `${filename}.ics`); 57 | } else { 58 | const link = document.createElement('a'); 59 | link.href = window.URL.createObjectURL(blob); 60 | link.setAttribute('download', `${filename}.ics`); 61 | document.body.appendChild(link); 62 | link.click(); 63 | document.body.removeChild(link); 64 | } 65 | } else { 66 | window.open(url, '_blank'); 67 | } 68 | }; 69 | 70 | handleDropdownToggle = e => { 71 | e.preventDefault(); 72 | this.setState(prevState => ({ dropdownOpen: !prevState.dropdownOpen })); 73 | }; 74 | 75 | render() { 76 | const { buttonProps, buttonText, className, dropdownProps, event, items, linkProps } = this.props; 77 | 78 | return ( 79 |
80 | 84 | {buttonText} 85 | 86 | {this.state.dropdownOpen && ( 87 | 92 | {items.map(item => ( 93 | 99 | {item} 100 | 101 | ))} 102 | 103 | )} 104 |
105 | ); 106 | } 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | SHARE_SITES 3 | } from './enums'; 4 | 5 | 6 | /** 7 | * Converts Date String with UTC timezone to date consumable by calendar 8 | * apps. Changes +00:00 to Z. 9 | * @param {string} Date in YYYYMMDDTHHmmssZ format 10 | * @returns {string} Date with +00:00 replaceed with Z 11 | */ 12 | export const formatDate = date => date && date.replace('+00:00', 'Z'); 13 | 14 | export const formatDuration = duration => { 15 | if (typeof duration === 'string') return duration; 16 | const parts = duration.toString().split('.'); 17 | if (parts.length < 2) { 18 | parts.push('00'); 19 | } 20 | 21 | return parts.map(part => part.length === 2 ? part : `0${part}`).join(''); 22 | }; 23 | 24 | /** 25 | * Tests provided UserAgent against Known Mobile User Agents 26 | * @returns {bool} isMobileDevice 27 | */ 28 | export const isMobile = () => /Mobile|iP(hone|od|ad)|Android|BlackBerry|IEMobile/.test(window.navigator.userAgent || window.navigator.vendor || window.opera); 29 | 30 | /** 31 | * Tests userAgent to see if browser is IE 32 | * @returns {bool} isInternetExplorer 33 | */ 34 | export const isInternetExplorer = () => /MSIE/.test(window.navigator.userAgent) || /Trident/.test(window.navigator.userAgent); 35 | 36 | export const escapeICSDescription = description => description.replace(/(\r?\n|
)/g, '\\n'); 37 | 38 | /** 39 | * Takes an event object and returns a Google Calendar Event URL 40 | * @param {string} event.description 41 | * @param {string} event.endDatetime 42 | * @param {string} event.location 43 | * @param {string} event.startDatetime 44 | * @param {string} event.title 45 | * @returns {string} Google Calendar Event URL 46 | */ 47 | const googleShareUrl = ({ 48 | description, 49 | endDatetime, 50 | location, 51 | startDatetime, 52 | timezone, 53 | title, 54 | }) => 55 | `https://calendar.google.com/calendar/render?action=TEMPLATE&dates=${ 56 | startDatetime 57 | }/${endDatetime}${timezone && `&ctz=${timezone}`}&location=${location}&text=${title}&details=${description}`; 58 | 59 | /** 60 | * Takes an event object and returns a Yahoo Calendar Event URL 61 | * @param {string} event.description 62 | * @param {string} event.duration 63 | * @param {string} event.location 64 | * @param {string} event.startDatetime 65 | * @param {string} event.title 66 | * @returns {string} Yahoo Calendar Event URL 67 | */ 68 | const yahooShareUrl = ({ 69 | description, 70 | duration, 71 | location, 72 | startDatetime, 73 | title, 74 | }) => 75 | `https://calendar.yahoo.com/?v=60&view=d&type=20&title=${title}&st=${ 76 | startDatetime 77 | }&dur=${duration}&desc=${description}&in_loc=${location}`; 78 | 79 | /** 80 | * Takes an event object and returns an array to be downloaded as ics file 81 | * @param {string} event.description 82 | * @param {string} event.endDatetime 83 | * @param {string} event.location 84 | * @param {string} event.startDatetime 85 | * @param {string} event.title 86 | * @returns {array} ICS Content 87 | */ 88 | const buildShareFile = ({ 89 | description = '', 90 | ctz = '', 91 | endDatetime, 92 | location = '', 93 | startDatetime, 94 | timezone = '', 95 | title = '', 96 | }) => { 97 | let content = [ 98 | 'BEGIN:VCALENDAR', 99 | 'VERSION:2.0', 100 | 'BEGIN:VEVENT', 101 | `URL:${document.URL}`, 102 | 'METHOD:PUBLISH', 103 | // TODO: Will need to parse the date without Z for ics 104 | // This means I'll probably have to require a date lib - luxon most likely or datefns 105 | timezone === '' ? `DTSTART:${startDatetime}` : `DTSTART;TZID=${timezone}:${startDatetime}`, 106 | timezone === '' ? `DTEND:${endDatetime}` : `DTEND;TZID=${timezone}:${endDatetime}`, 107 | `SUMMARY:${title}`, 108 | `DESCRIPTION:${escapeICSDescription(description)}`, 109 | `LOCATION:${location}`, 110 | 'END:VEVENT', 111 | 'END:VCALENDAR', 112 | ].join('\n'); 113 | 114 | return isMobile() ? encodeURI(`data:text/calendar;charset=utf8,${content}`) : content; 115 | } 116 | 117 | /** 118 | * Takes an event object and a type of URL and returns either a calendar event 119 | * URL or the contents of an ics file. 120 | * @param {string} event.description 121 | * @param {string} event.duration 122 | * @param {string} event.endDatetime 123 | * @param {string} event.location 124 | * @param {string} event.startDatetime 125 | * @param {string} event.title 126 | * @param {enum} type One of SHARE_SITES from ./enums 127 | */ 128 | export const buildShareUrl = ({ 129 | description = '', 130 | duration, 131 | endDatetime, 132 | location = '', 133 | startDatetime, 134 | timezone = '', 135 | title = '' 136 | }, 137 | type, 138 | ) => { 139 | const encodeURI = type !== SHARE_SITES.ICAL && type !== SHARE_SITES.OUTLOOK; 140 | 141 | const data = { 142 | description: encodeURI ? encodeURIComponent(description) : description, 143 | duration: formatDuration(duration), 144 | endDatetime: formatDate(endDatetime), 145 | location: encodeURI ? encodeURIComponent(location) : location, 146 | startDatetime: formatDate(startDatetime), 147 | timezone, 148 | title: encodeURI ? encodeURIComponent(title) : title, 149 | }; 150 | 151 | switch (type) { 152 | case SHARE_SITES.GOOGLE: 153 | return googleShareUrl(data); 154 | case SHARE_SITES.YAHOO: 155 | return yahooShareUrl(data); 156 | default: 157 | return buildShareFile(data); 158 | } 159 | }; -------------------------------------------------------------------------------- /src/lib/utils.test.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import { 3 | SHARE_SITES 4 | } from './enums'; 5 | import { 6 | buildShareUrl, 7 | formatDate, 8 | formatDuration, 9 | isInternetExplorer, 10 | isMobile, 11 | escapeICSDescription 12 | } from './utils'; 13 | 14 | const testEvent = { 15 | description: 'Description of event. Going to have a lot of fun doing things that we scheduled ahead of time.', 16 | duration: '0200', 17 | endDatetime: '20150126T020000+00:00', 18 | location: 'NYC', 19 | startDatetime: '20150126T000000+00:00', 20 | title: 'Super Fun Event', 21 | } 22 | 23 | const expectedOutputs = { 24 | google: 'https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20150126T000000Z/20150126T020000Z&location=NYC&text=Super%20Fun%20Event&details=Description%20of%20event.%20Going%20to%20have%20a%20lot%20of%20fun%20doing%20things%20that%20we%20scheduled%20ahead%20of%20time.', 25 | yahoo: 'https://calendar.yahoo.com/?v=60&view=d&type=20&title=Super%20Fun%20Event&st=20150126T000000Z&dur=0200&desc=Description%20of%20event.%20Going%20to%20have%20a%20lot%20of%20fun%20doing%20things%20that%20we%20scheduled%20ahead%20of%20time.&in_loc=NYC', 26 | ics: 'BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nURL:http://localhost/\nMETHOD:PUBLISH\nDTSTART:20150126T000000Z\nDTEND:20150126T020000Z\nSUMMARY:Super Fun Event\nDESCRIPTION:Description of event. Going to have a lot of fun doing things that we scheduled ahead of time.\nLOCATION:NYC\nEND:VEVENT\nEND:VCALENDAR', 27 | icsMobile: 'data:text/calendar;charset=utf8,BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nURL:http://localhost/\nMETHOD:PUBLISH\nDTSTART:20150126T000000Z\nDTEND:20150126T020000Z\nSUMMARY:Super Fun Event\nDESCRIPTION:Description of event. Going to have a lot of fun doing things that we scheduled ahead of time.\nLOCATION:NYC\nEND:VEVENT\nEND:VCALENDAR', 28 | } 29 | 30 | describe('formatDate', () => { 31 | it('replaces +00:00 from a date string with Z', () => { 32 | expect(formatDate('20180603T172721+00:00')).toEqual('20180603T172721Z'); 33 | }); 34 | }); 35 | 36 | describe('formatDuration', () => { 37 | it('converts number 2 to string 0200', () => { 38 | expect(formatDuration(2)).toEqual('0200'); 39 | }); 40 | 41 | it('converts number 2.25 to 0225', () => { 42 | expect(formatDuration(2.25)).toEqual('0225'); 43 | }); 44 | 45 | it('returns string 0200 as it was received', () => { 46 | expect(formatDuration('0200')).toEqual('0200'); 47 | }); 48 | }); 49 | 50 | describe('buildShareUrl', () => { 51 | describe('Google', () => { 52 | it('returns a proper Google share URL', () => { 53 | const result = buildShareUrl(testEvent, SHARE_SITES.GOOGLE); 54 | expect(result).toEqual(expectedOutputs.google); 55 | }); 56 | }); 57 | 58 | describe('Yahoo', () => { 59 | it('returns a proper Yahoo share URL', () => { 60 | const result = buildShareUrl(testEvent, SHARE_SITES.YAHOO); 61 | expect(result).toEqual(expectedOutputs.yahoo) 62 | }); 63 | 64 | it('returns a proper Yahoo share URL when duration is a number', () => { 65 | const result = buildShareUrl({ 66 | ...testEvent, 67 | duration: 2 68 | }, SHARE_SITES.YAHOO); 69 | expect(result).toEqual(expectedOutputs.yahoo); 70 | }); 71 | }); 72 | 73 | 74 | describe('iCal', () => { 75 | it('returns a proper iCal content object', () => { 76 | const result = buildShareUrl(testEvent, SHARE_SITES.ICAL); 77 | expect(result).toEqual(expectedOutputs.ics); 78 | }); 79 | 80 | it('prepends a data URL when userAgent is mobile', () => { 81 | navigator.__defineGetter__('userAgent', function () { 82 | return "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"; 83 | }); 84 | 85 | const result = buildShareUrl(testEvent, SHARE_SITES.ICAL); 86 | expect(result).toEqual(encodeURI(expectedOutputs.icsMobile)); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('isInternetExplorer', () => { 92 | it('returns true is userAgent is IE 11', () => { 93 | navigator.__defineGetter__('userAgent', function () { 94 | return "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; rv:11.0) like Gecko"; 95 | }); 96 | 97 | const result = isInternetExplorer(); 98 | expect(result).toBe(true); 99 | }); 100 | 101 | it('returns true is userAgent is IE 10', () => { 102 | navigator.__defineGetter__('userAgent', function () { 103 | return "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; WOW64; Trident/6.0; .NET4.0E; .NET4.0C; .NET CLR 3.5.30729; .NET CLR 2.0.50727; .NET CLR 3.0.30729)"; 104 | }); 105 | 106 | const result = isInternetExplorer(); 107 | expect(result).toBe(true); 108 | }); 109 | 110 | it('returns true is userAgent is IE 9', () => { 111 | navigator.__defineGetter__('userAgent', function () { 112 | return "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET4.0C; .NET4.0E)"; 113 | }); 114 | 115 | const result = isInternetExplorer(); 116 | expect(result).toBe(true); 117 | }); 118 | 119 | it('returns false is userAgent is MS Edge', () => { 120 | navigator.__defineGetter__('userAgent', function () { 121 | return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.67 Safari/537.36"; 122 | }); 123 | 124 | const result = isInternetExplorer(); 125 | expect(result).toBe(false); 126 | }); 127 | 128 | it('returns false is userAgent is not IE', () => { 129 | navigator.__defineGetter__('userAgent', function () { 130 | return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.67 Safari/537.36"; 131 | }); 132 | 133 | const result = isInternetExplorer(); 134 | expect(result).toBe(false); 135 | }); 136 | }) 137 | 138 | describe('isMobile', () => { 139 | it('returns true if userAgent is iPhone', () => { 140 | navigator.__defineGetter__('userAgent', function () { 141 | return "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"; 142 | }); 143 | 144 | const result = isMobile(); 145 | expect(result).toBe(true); 146 | }); 147 | 148 | it('returns true if userAgent is Android', () => { 149 | navigator.__defineGetter__('userAgent', function () { 150 | return "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.109 Mobile Safari/537.36"; 151 | }); 152 | 153 | const result = isMobile(); 154 | expect(result).toBe(true); 155 | }); 156 | 157 | it('returns false if userAgent is desktop', () => { 158 | navigator.__defineGetter__('userAgent', function () { 159 | return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.67 Safari/537.36"; 160 | }); 161 | 162 | const result = isMobile(); 163 | expect(result).toBe(false); 164 | }) 165 | }); 166 | 167 | describe('escapeICSDescription', () => { 168 | it('replaces carriage returns with newline characters', () => { 169 | const result = escapeICSDescription('Line One \r\nLine Two'); 170 | expect(result).toEqual('Line One \\nLine Two'); 171 | }); 172 | 173 | it('replaces
characters with newline characters', () => { 174 | const expectedResult = 'Line One \\nLineTwo'; 175 | expect(escapeICSDescription('Line One
LineTwo')).toEqual(expectedResult); 176 | expect(escapeICSDescription('Line One
LineTwo')).toEqual(expectedResult); 177 | expect(escapeICSDescription('Line One
LineTwo')).toEqual(expectedResult); 178 | }); 179 | }); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: path.join(__dirname, "src/docs"), 6 | output: { 7 | path: path.join(__dirname, "docs"), 8 | filename: "bundle.js" 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.(js|jsx)$/, 14 | use: "babel-loader", 15 | exclude: /node_modules/ 16 | }, 17 | { 18 | test: /\.css$/, 19 | use: ["style-loader", "css-loader"] 20 | } 21 | ] 22 | }, 23 | plugins: [ 24 | new HtmlWebpackPlugin({ 25 | template: path.join(__dirname, "src/docs/index.html") 26 | }) 27 | ], 28 | resolve: { 29 | extensions: [".js", ".jsx"] 30 | }, 31 | devServer: { 32 | contentBase: path.join(__dirname, "docs"), 33 | host: '0.0.0.0', 34 | port: 8000, 35 | stats: "minimal" 36 | } 37 | }; 38 | --------------------------------------------------------------------------------