Commit 2b2f88de authored by David Haynes's avatar David Haynes 🙆
Browse files

Merge branch '22-better-export' into 'master'

Resolve "Add options for calendar export to support different systems"

Closes #22

See merge request !21
parents ac0d1848 5e5b1258
Pipeline #2870 passed with stages
in 13 minutes and 16 seconds
......@@ -4,7 +4,7 @@
"main": "index.js",
"scripts": {
"build": "webpack --config webpack.prod.js --debug --progress",
"start": "webpack-dev-server --config webpack.dev.js --inline --open",
"start": "webpack-dev-server --config webpack.dev.js --inline",
"prod": "node app.js"
},
"license": "Apache",
......
......@@ -6,6 +6,9 @@ import Header from './Header';
require('../css/core.css');
/**
* The root component for the app
*/
const App = () => (
<div>
<Container>
......
......@@ -11,6 +11,10 @@ interface CourseSectionCardProps {
require('../css/button-text-override.css');
/**
* Renders information about a single course section, and includes a
* button for adding/removing it from the current schedule.
*/
const CourseSectionCard = ({
courseSection,
courseSectionAction,
......
......@@ -9,6 +9,10 @@ interface CourseSectionListProps {
destructive?: boolean;
}
/**
* Renders a list of CourseSectionCards for every course section in
* the current schedule.
*/
const CourseSectionList = ({
courseSections,
courseSectionAction,
......
import * as React from 'react';
import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
import { downloadFile } from '../util/utilities';
interface ExportModalProps {
isModalOpen: boolean;
toggleModal: () => void;
calendarUrl: () => string;
openCalendarAsWebcal: () => void;
downloadIcs: () => Promise<void>;
}
/**
* Modal view that contains buttons for exporting your schedule as
* well as instructions for importing your schedule into different
* calendar managers
*/
const ExportModal = ({
isModalOpen,
toggleModal,
calendarUrl,
openCalendarAsWebcal,
downloadIcs,
}: ExportModalProps) => (
<Modal isOpen={isModalOpen} toggle={toggleModal}>
<ModalHeader toggle={toggleModal}>Your calendar has been generated!</ModalHeader>
<ModalBody>
<h5>Apple Calendar</h5>
To add your schedule to Apple Calendar, click the "Add to calendar" button below. If you are on a device
running macOS or iOS, this will open a dialogue which will walk you through adding the calendar.
<hr />
<h5>Google Calendar</h5>
<strong>On desktop:</strong>
<br />
Open your <a href="https://calendar.google.com/">Google Calendar</a>. Click the "Settings" button in the top
right, and then click the Settings tab. In the menu on the left, click "Add calendar" and "From URL". Now,
paste the following link inside the text box: <br />
<code>{calendarUrl()}</code>
<br />
<strong>On mobile (Android only):</strong>
<br />
Click the "Download calendar file" button. This will download the calendar file which you may then open and
add to your calendar.
<hr />
<h5>.ics file</h5>
To download a .ics file containing your schedule, click the "Download calendar file" button below.
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={downloadIcs}>
Download calendar file
</Button>
<Button color="primary" onClick={openCalendarAsWebcal}>
Add to calendar
</Button>{' '}
</ModalFooter>
</Modal>
);
export default ExportModal;
import * as React from 'react';
import { Col, Row, UncontrolledTooltip } from 'reactstrap';
/**
* Renders the app header with information and instructions for using Schedules.
*/
const Header = () => (
<div>
<Row className="justify-content-center my-5">
......
......@@ -2,30 +2,41 @@ import * as React from 'react';
import { Button, Card, CardBody, CardTitle, Collapse, Row } from 'reactstrap';
import CourseSection from '../util/CourseSection';
import CourseSectionList from './CourseSectionList';
import ExportModal from './ExportModal';
interface ScheduleBadgeProps {
schedule: CourseSection[];
generateCalendar: (schedule: CourseSection[]) => Promise<void>;
removeCourseSection: (courseSection: CourseSection) => void;
generateCalendarUrl: () => string;
openCalendarAsWebcal: () => void;
downloadIcs: () => Promise<void>;
}
interface State {
collapse: boolean;
isModalOpen: boolean;
}
require('../css/icon-badge.css');
/**
* Contains all functionality for viewing your schedule, such as the
* shopping cart, list of course sections, and the generate calendar modal.
*
* TODO: Split this component up
*/
class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
constructor(props: ScheduleBadgeProps) {
super(props);
this.state = { collapse: false };
this.state = { collapse: false, isModalOpen: false };
}
toggle = () => this.setState({ collapse: !this.state.collapse });
toggleCollapse = () => this.setState({ collapse: !this.state.collapse });
toggleModal = () => this.setState({ isModalOpen: !this.state.isModalOpen });
render() {
const { schedule, removeCourseSection, generateCalendar } = this.props;
const { schedule, removeCourseSection, generateCalendarUrl, openCalendarAsWebcal, downloadIcs } = this.props;
return (
<div>
<Row className="justify-content-end">
......@@ -35,7 +46,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
<i className="fa fas fa-shopping-bag fa-stack-1x" />
</span>
}
onClick={this.toggle}
onClick={this.toggleCollapse}
id="cart"
/>
</Row>
......@@ -46,7 +57,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
<Row className="my-3">
<h1 className="px-5">Your Schedule</h1>
<Button className="ml-auto px-5" outline color="danger" onClick={this.toggle}>
<Button className="ml-auto px-5" outline color="danger" onClick={this.toggleCollapse}>
Close
</Button>
</Row>
......@@ -62,7 +73,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
size="sm"
outline
color="primary"
onClick={() => generateCalendar(schedule)}
onClick={this.toggleModal}
disabled={schedule.length === 0}>
Generate
</Button>
......@@ -70,6 +81,13 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
</CardBody>
</Card>
</Collapse>
<ExportModal
isModalOpen={this.state.isModalOpen}
toggleModal={this.toggleModal}
calendarUrl={generateCalendarUrl}
openCalendarAsWebcal={openCalendarAsWebcal}
downloadIcs={downloadIcs}
/>
</div>
);
}
......
import * as React from 'react';
import CourseSection from '../util/CourseSection';
import ScheduleBadge from './ScheduleBadge';
import ApiService from '../util/ApiService';
import { downloadFile } from '../util/utilities';
interface ScheduleRootProps {
schedule: CourseSection[];
removeCourseSection: (courseSection: CourseSection) => any;
generateCalendarUrl: () => string;
openCalendarAsWebcal: () => void;
downloadIcs: () => Promise<void>;
}
const generateSchedule = async (schedule: CourseSection[]) => {
const crns = schedule.map(section => section.crn);
ApiService.subscribeToCalendar(crns);
};
const ScheduleRoot = ({ schedule, removeCourseSection }: ScheduleRootProps) => (
/**
* Weird component that renders the ScheduleBadge
*
* TODO: Remove this component? Or maybe refactor some of ScheduleBadge into this
*/
const ScheduleRoot = ({
schedule,
removeCourseSection,
generateCalendarUrl,
openCalendarAsWebcal,
downloadIcs,
}: ScheduleRootProps) => (
<div>
<ScheduleBadge
schedule={schedule}
removeCourseSection={removeCourseSection}
generateCalendar={generateSchedule}
generateCalendarUrl={generateCalendarUrl}
openCalendarAsWebcal={openCalendarAsWebcal}
downloadIcs={downloadIcs}
/>
{/* <ScheduleList courses={schedule} selectCourseCallback={removeCourseSection} /> */}
{/* <button onClick={generateSchedule}>Generate Schedule</button> */}
......
......@@ -11,6 +11,10 @@ interface SearchRootProps {
addCourseSection: (courseSectionToAdd: CourseSection) => void;
}
/**
* Renders the SearchBar and a list of CourseSections returned from the Search.
* Also renders an error if there is one.
*/
const SearchRoot = ({ search, searchCourseSections, addCourseSection }: SearchRootProps) => (
<div>
<SearchBar onSearch={searchCourseSections} />
......@@ -26,6 +30,9 @@ const SearchRoot = ({ search, searchCourseSections, addCourseSection }: SearchRo
</div>
);
/**
* Renders a basic error message.
*/
const Error = () => (
<Row className="justify-content-center">
<Col md="8">
......
......@@ -2,11 +2,26 @@ import { connect } from 'react-redux';
import { removeCourseSection } from '../actions/schedule/schedule.actions';
import ScheduleRoot from '../components/ScheduleRoot';
import { State } from '../reducers';
import CourseSection from '../util/CourseSection';
import ApiService from '../util/ApiService';
import { downloadFile } from '../util/utilities';
const mapStateToProps = (state: State) => ({
schedule: state.schedule,
});
// Takes the current Redux state and returns objects which will be
// passed to the component as Props
const mapStateToProps = (state: State) => {
const crns = state.schedule ? state.schedule.map(section => section.crn) : [];
return {
schedule: state.schedule,
generateCalendarUrl: () => ApiService.generateCalendarUrl(crns),
openCalendarAsWebcal: () => ApiService.openCalendarAsWebcal(crns),
downloadIcs: async () => {
const icsText = await ApiService.fetchICal(crns);
downloadFile(icsText, 'GMU Fall 2018.ics');
},
};
};
// Pass mapStateToProps and other values to the component's props
export default connect(
mapStateToProps,
{ removeCourseSection }
......
......@@ -4,10 +4,13 @@ import { searchCourseSections } from '../actions/search/search.actions';
import SearchRoot from '../components/SearchRoot';
import { State } from '../reducers';
// Takes the current Redux state and returns objects which will be
// passed to the component as Props
const mapStateToProps = (state: State) => ({
search: state.search,
});
// Pass mapStateToProps and other values to the component's props
export default connect(
mapStateToProps,
{ searchCourseSections, addCourseSection }
......
......@@ -9,8 +9,12 @@ class ApiService {
searchCourseSections = async (crn: string): Promise<any[]> =>
fetchJson(`${this.apiRoot}/course_sections?crn=${crn}`);
subscribeToCalendar = (crns: string[]) =>
generateCalendarUrl = (crns: string[]): string => `${this.apiRoot}/schedules?crns=${crns.join(',')}`;
openCalendarAsWebcal = (crns: string[]) => {
window.open(`${this.webcalUrl}/schedules?crns=${crns.join(',')}`, '_self');
};
fetchICal = async (crns: string[]): Promise<string> =>
fetch(this.generateCalendarUrl(crns)).then(response => response.text());
}
const fetchJson = async (url: string): Promise<any> => fetch(url).then(response => response.json());
......@@ -24,7 +28,7 @@ const postJson = (endpoint: string, data: any): Promise<Response> =>
});
const local = 'localhost:3000/api';
const remote = `${window.location.hostname}/api`
const remote = `${window.location.hostname}/api`;
const apiUrl = process.env.NODE_ENV === 'development' ? `http://${local}` : `https://${remote}`;
const webcalUrl = process.env.NODE_ENV === 'development' ? `webcal://${local}` : `webcal://${remote}`;
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment