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 @@ ...@@ -4,7 +4,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "webpack --config webpack.prod.js --debug --progress", "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" "prod": "node app.js"
}, },
"license": "Apache", "license": "Apache",
......
...@@ -6,6 +6,9 @@ import Header from './Header'; ...@@ -6,6 +6,9 @@ import Header from './Header';
require('../css/core.css'); require('../css/core.css');
/**
* The root component for the app
*/
const App = () => ( const App = () => (
<div> <div>
<Container> <Container>
......
...@@ -11,6 +11,10 @@ interface CourseSectionCardProps { ...@@ -11,6 +11,10 @@ interface CourseSectionCardProps {
require('../css/button-text-override.css'); 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 = ({ const CourseSectionCard = ({
courseSection, courseSection,
courseSectionAction, courseSectionAction,
......
...@@ -9,6 +9,10 @@ interface CourseSectionListProps { ...@@ -9,6 +9,10 @@ interface CourseSectionListProps {
destructive?: boolean; destructive?: boolean;
} }
/**
* Renders a list of CourseSectionCards for every course section in
* the current schedule.
*/
const CourseSectionList = ({ const CourseSectionList = ({
courseSections, courseSections,
courseSectionAction, 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 * as React from 'react';
import { Col, Row, UncontrolledTooltip } from 'reactstrap'; import { Col, Row, UncontrolledTooltip } from 'reactstrap';
/**
* Renders the app header with information and instructions for using Schedules.
*/
const Header = () => ( const Header = () => (
<div> <div>
<Row className="justify-content-center my-5"> <Row className="justify-content-center my-5">
......
...@@ -2,30 +2,41 @@ import * as React from 'react'; ...@@ -2,30 +2,41 @@ import * as React from 'react';
import { Button, Card, CardBody, CardTitle, Collapse, Row } from 'reactstrap'; import { Button, Card, CardBody, CardTitle, Collapse, Row } from 'reactstrap';
import CourseSection from '../util/CourseSection'; import CourseSection from '../util/CourseSection';
import CourseSectionList from './CourseSectionList'; import CourseSectionList from './CourseSectionList';
import ExportModal from './ExportModal';
interface ScheduleBadgeProps { interface ScheduleBadgeProps {
schedule: CourseSection[]; schedule: CourseSection[];
generateCalendar: (schedule: CourseSection[]) => Promise<void>;
removeCourseSection: (courseSection: CourseSection) => void; removeCourseSection: (courseSection: CourseSection) => void;
generateCalendarUrl: () => string;
openCalendarAsWebcal: () => void;
downloadIcs: () => Promise<void>;
} }
interface State { interface State {
collapse: boolean; collapse: boolean;
isModalOpen: boolean;
} }
require('../css/icon-badge.css'); 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> { class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
constructor(props: ScheduleBadgeProps) { constructor(props: ScheduleBadgeProps) {
super(props); 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() { render() {
const { schedule, removeCourseSection, generateCalendar } = this.props; const { schedule, removeCourseSection, generateCalendarUrl, openCalendarAsWebcal, downloadIcs } = this.props;
return ( return (
<div> <div>
<Row className="justify-content-end"> <Row className="justify-content-end">
...@@ -35,7 +46,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> { ...@@ -35,7 +46,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
<i className="fa fas fa-shopping-bag fa-stack-1x" /> <i className="fa fas fa-shopping-bag fa-stack-1x" />
</span> </span>
} }
onClick={this.toggle} onClick={this.toggleCollapse}
id="cart" id="cart"
/> />
</Row> </Row>
...@@ -46,7 +57,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> { ...@@ -46,7 +57,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
<Row className="my-3"> <Row className="my-3">
<h1 className="px-5">Your Schedule</h1> <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 Close
</Button> </Button>
</Row> </Row>
...@@ -62,7 +73,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> { ...@@ -62,7 +73,7 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
size="sm" size="sm"
outline outline
color="primary" color="primary"
onClick={() => generateCalendar(schedule)} onClick={this.toggleModal}
disabled={schedule.length === 0}> disabled={schedule.length === 0}>
Generate Generate
</Button> </Button>
...@@ -70,6 +81,13 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> { ...@@ -70,6 +81,13 @@ class ScheduleBadge extends React.Component<ScheduleBadgeProps, State> {
</CardBody> </CardBody>
</Card> </Card>
</Collapse> </Collapse>
<ExportModal
isModalOpen={this.state.isModalOpen}
toggleModal={this.toggleModal}
calendarUrl={generateCalendarUrl}
openCalendarAsWebcal={openCalendarAsWebcal}
downloadIcs={downloadIcs}
/>
</div> </div>
); );
} }
......
import * as React from 'react'; import * as React from 'react';
import CourseSection from '../util/CourseSection'; import CourseSection from '../util/CourseSection';
import ScheduleBadge from './ScheduleBadge'; import ScheduleBadge from './ScheduleBadge';
import ApiService from '../util/ApiService';
import { downloadFile } from '../util/utilities';
interface ScheduleRootProps { interface ScheduleRootProps {
schedule: CourseSection[]; schedule: CourseSection[];
removeCourseSection: (courseSection: CourseSection) => any; removeCourseSection: (courseSection: CourseSection) => any;
generateCalendarUrl: () => string;
openCalendarAsWebcal: () => void;
downloadIcs: () => Promise<void>;
} }
const generateSchedule = async (schedule: CourseSection[]) => { /**
const crns = schedule.map(section => section.crn); * Weird component that renders the ScheduleBadge
ApiService.subscribeToCalendar(crns); *
}; * TODO: Remove this component? Or maybe refactor some of ScheduleBadge into this
*/
const ScheduleRoot = ({ schedule, removeCourseSection }: ScheduleRootProps) => ( const ScheduleRoot = ({
schedule,
removeCourseSection,
generateCalendarUrl,
openCalendarAsWebcal,
downloadIcs,
}: ScheduleRootProps) => (
<div> <div>
<ScheduleBadge <ScheduleBadge
schedule={schedule} schedule={schedule}
removeCourseSection={removeCourseSection} removeCourseSection={removeCourseSection}
generateCalendar={generateSchedule} generateCalendarUrl={generateCalendarUrl}
openCalendarAsWebcal={openCalendarAsWebcal}
downloadIcs={downloadIcs}
/> />
{/* <ScheduleList courses={schedule} selectCourseCallback={removeCourseSection} /> */} {/* <ScheduleList courses={schedule} selectCourseCallback={removeCourseSection} /> */}
{/* <button onClick={generateSchedule}>Generate Schedule</button> */} {/* <button onClick={generateSchedule}>Generate Schedule</button> */}
......
...@@ -11,6 +11,10 @@ interface SearchRootProps { ...@@ -11,6 +11,10 @@ interface SearchRootProps {
addCourseSection: (courseSectionToAdd: CourseSection) => void; 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) => ( const SearchRoot = ({ search, searchCourseSections, addCourseSection }: SearchRootProps) => (
<div> <div>
<SearchBar onSearch={searchCourseSections} /> <SearchBar onSearch={searchCourseSections} />
...@@ -26,6 +30,9 @@ const SearchRoot = ({ search, searchCourseSections, addCourseSection }: SearchRo ...@@ -26,6 +30,9 @@ const SearchRoot = ({ search, searchCourseSections, addCourseSection }: SearchRo
</div> </div>
); );
/**
* Renders a basic error message.
*/
const Error = () => ( const Error = () => (
<Row className="justify-content-center"> <Row className="justify-content-center">
<Col md="8"> <Col md="8">
......
...@@ -2,11 +2,26 @@ import { connect } from 'react-redux'; ...@@ -2,11 +2,26 @@ import { connect } from 'react-redux';
import { removeCourseSection } from '../actions/schedule/schedule.actions'; import { removeCourseSection } from '../actions/schedule/schedule.actions';
import ScheduleRoot from '../components/ScheduleRoot'; import ScheduleRoot from '../components/ScheduleRoot';
import { State } from '../reducers'; import { State } from '../reducers';
import CourseSection from '../util/CourseSection';
import ApiService from '../util/ApiService';
import { downloadFile } from '../util/utilities';
const mapStateToProps = (state: State) => ({ // 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, 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( export default connect(
mapStateToProps, mapStateToProps,
{ removeCourseSection } { removeCourseSection }
......
...@@ -4,10 +4,13 @@ import { searchCourseSections } from '../actions/search/search.actions'; ...@@ -4,10 +4,13 @@ import { searchCourseSections } from '../actions/search/search.actions';
import SearchRoot from '../components/SearchRoot'; import SearchRoot from '../components/SearchRoot';
import { State } from '../reducers'; 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) => ({ const mapStateToProps = (state: State) => ({
search: state.search, search: state.search,
}); });
// Pass mapStateToProps and other values to the component's props
export default connect( export default connect(
mapStateToProps, mapStateToProps,
{ searchCourseSections, addCourseSection } { searchCourseSections, addCourseSection }
......
...@@ -9,8 +9,12 @@ class ApiService { ...@@ -9,8 +9,12 @@ class ApiService {
searchCourseSections = async (crn: string): Promise<any[]> => searchCourseSections = async (crn: string): Promise<any[]> =>
fetchJson(`${this.apiRoot}/course_sections?crn=${crn}`); 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'); 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()); 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> => ...@@ -24,7 +28,7 @@ const postJson = (endpoint: string, data: any): Promise<Response> =>
}); });
const local = 'localhost:3000/api'; 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 apiUrl = process.env.NODE_ENV === 'development' ? `http://${local}` : `https://${remote}`;
const webcalUrl = process.env.NODE_ENV === 'development' ? `webcal://${local}` : `webcal://${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