Commit d3970aa1 authored by Zac Wood's avatar Zac Wood
Browse files

Rewrote search + schedule pages with React

parent 080037ca
Pipeline #4235 failed with stage
in 3 minutes and 32 seconds
import Cart from 'src/cart';
import Cart from 'src/Cart';
import { saveAs } from 'file-saver';
import html2canvas from 'html2canvas';
import $ from 'jquery';
......
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
import Cart from 'src/cart';
// import Cart from 'src/cart';
/**
* Either adds or removes a section from the cart depending on
* if it is currently in the cart.
*/
const addOrRemoveFromCart = async (event, sectionNode) => {
event && event.stopPropagation();
const section = { ...sectionNode.dataset };
// /**
// * Toggles the display of the schedule
// */
// const toggleSections = course => {
// const sections = course.querySelector('.sections');
// const chev = $(course.querySelector('#course-chevron'));
// const label = course.querySelector('#chevron-label');
await Cart.toggleSection(section);
const icon = $(sectionNode.querySelector('.add-remove-btn #icon'));
const text = sectionNode.querySelector('.add-remove-btn .text');
if (Cart.includesSection(section)) {
icon.addClass('fa-minus').removeClass('fa-plus');
text.innerText = 'Remove';
} else {
icon.addClass('fa-plus').removeClass('fa-minus');
text.innerText = 'Add';
}
};
// if (sections.style.display === 'flex') {
// sections.style.display = 'none';
// chev.addClass('fa-chevron-down').removeClass('fa-chevron-up');
// label.innerText = 'Expand';
// } else {
// sections.style.display = 'flex';
// chev.addClass('fa-chevron-up').removeClass('fa-chevron-down');
// label.innerText = 'Minimize';
// }
// };
/**
* Toggles the display of the schedule
*/
const toggleSections = course => {
const sections = course.querySelector('.sections');
const chev = $(course.querySelector('#course-chevron'));
const label = course.querySelector('#chevron-label');
import React from 'react';
import ReactDOM from 'react-dom';
import CourseList from 'src/CourseList';
if (sections.style.display === 'flex') {
sections.style.display = 'none';
chev.addClass('fa-chevron-down').removeClass('fa-chevron-up');
label.innerText = 'Expand';
} else {
sections.style.display = 'flex';
chev.addClass('fa-chevron-up').removeClass('fa-chevron-down');
label.innerText = 'Minimize';
}
};
const initSearchListeners = () => {
const courseCards = Array.from(document.querySelectorAll('.course-card'));
courseCards.forEach(card => {
card.onclick = () => toggleSections(card);
});
const sectionItems = Array.from(document.querySelectorAll('.section-item'));
sectionItems.forEach(item => (item.onclick = event => addOrRemoveFromCart(event, item)));
};
document.addEventListener('DOMContentLoaded', initSearchListeners);
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(<CourseList courses={gon.courses} />, document.getElementById('root'));
});
import React from 'react';
import BigCalendar from 'react-big-calendar';
import moment from 'moment';
import '!style-loader!css-loader!react-big-calendar/lib/css/react-big-calendar.css';
const localizer = BigCalendar.momentLocalizer(moment);
const Calendar = props => (
<div style={{ backgroundColor: 'white', padding: '24px' }}>
<BigCalendar
localizer={localizer}
events={props.events}
title=""
defaultView="week"
views={['week', 'day']}
startAccessor="start"
endAccessor="end"
defaultDate={moment('2019-01-14').toDate()}
formats={{
dayFormat: (date, culture, localizer) => localizer.format(date, 'ddd', culture),
dayRangeHeaderFormat: () => '',
}}
style={{ height: '75vh' }}
/>
</div>
);
export default Calendar;
import React from 'react';
import Calendar from 'src/Calendar';
import Cart from 'src/Cart';
import SectionList from 'src/SectionList';
import QuickAdd from 'src/QuickAdd';
import moment from 'moment';
export default class CalendarPage extends React.Component {
state = { events: [], sections: [] };
constructor(props) {
super(props);
this.loadEvents();
}
loadEvents = async () => {
const response = await fetch(`/schedule/events?crns=${Cart.crns.join(',')}`);
const json = await response.json();
this.setState({ ...json });
Cart.crns = json.sections.map(s => s.crn);
};
events = () => {
return this.state.events.filter(e => e.active).map(e => ({ ...e, start: moment(e.start).toDate(), end: moment(e.end).toDate() }));
};
toggleSection = crn => {
const events = this.state.events.map(e => ({ ...e, active: e.crn == crn ? !e.active : e.active }));
this.setState({ events });
};
removeAll = () => {
Cart.crns = [];
location.reload();
};
render() {
return (
<div>
<Calendar events={this.events()} />
{this.state.sections.length > 0 ? (
<div className="d-flex justify-content-between align-items-end">
<h2 className="mt-4">Your Schedule</h2>{' '}
<button type="button" onClick={this.removeAll} className="btn btn-danger mb-8">
Remove all sections
</button>
</div>
) : null}
<SectionList onClick={this.toggleSection} sections={this.state.sections} expanded={true} />
<QuickAdd loadCalendar={this.loadEvents} />
</div>
);
}
}
import React from 'react';
export default class Chevron extends React.Component {
constructor(props) {
super(props);
}
render() {
const base = { display: 'block', textAlign: 'center' };
return (
<div>
<div style={this.props.open ? { ...base } : { display: 'none' }}>
<p id="chevron-label" style={{ marginBottom: '4px', fontSize: '10px' }}>
Minimize
</p>
<i id="course-chevron" className="fas fa-chevron-up" />
</div>
<div style={this.props.open ? { display: 'none' } : { ...base }}>
<p id="chevron-label" style={{ marginBottom: '4px', fontSize: '10px' }}>
Expand
</p>
<i id="course-chevron" className="fas fa-chevron-down" />
</div>
</div>
);
}
}
import React from 'react';
import SectionList from 'src/SectionList';
export default class Course extends React.Component {
constructor(props) {
super(props);
this.state = { expanded: false, sections: [] };
}
async onClick() {
if (this.state.sections.length === 0) {
const resp = await fetch(`/api/course_sections?course_id=${this.props.id}`);
const json = await resp.json();
this.setState({ sections: json });
}
this.setState({ expanded: !this.state.expanded });
}
prereqs = () => {
if (this.props.prereqs) {
const [first, rest] = this.props.prereqs.split(':');
const [reqs, note] = rest.split('.');
return (
<p>
<strong>{first}</strong>
{reqs}
<sub>{note}</sub>
</p>
);
}
return <div />;
};
// <% first, rest = course.prereqs.split(':') %>
// <% prereqs, note = rest.split('.') %>
// <p><strong><%= first %>:</strong> <%= prereqs %> <sub><%= note %></sub></p>
render() {
const { id, subject, course_number, title, credits, description, url } = this.props;
return (
<div className="card course-card" onClick={() => this.onClick()}>
<div className="card-header">
<div className="row">
<div className="col">
<a href={url}>
<h4 className="title">{`${subject} ${course_number}`}</h4>
</a>
</div>
</div>
<div className="d-md-flex justify-content-between">
<h5>
<em>{title}</em>
</h5>
<div className="attr-list justify-content-start">
<div className="attr">
<div className="icon">
<i className="fa fa-book" />
</div>
{credits} credits
</div>
</div>
</div>
</div>
<div className="card-body">
<p>{description}</p>
{this.prereqs()}
<div className="list-group list-group-flush sections" style={{ display: 'none' }} />
<SectionList {...this.state} expandable={true} />
</div>
</div>
);
}
}
import React from 'react';
import Course from 'src/Course';
export default class CourseList extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
{this.props.courses.map(course => (
<Course key={course.id} {...course} />
))}
</div>
);
}
}
import React from 'react';
import Cart from 'src/Cart';
export default class QuickAdd extends React.Component {
constructor(props) {
super(props);
this.state = { crns: '' };
}
add = e => {
e.preventDefault();
const crns = this.state.crns.split(',');
crns.forEach(c => c.length === 5 && Cart.addCrn(c));
this.props.loadCalendar();
};
render() {
return (
<div>
<h3 className="quick-add-header">Quick add</h3>
<p>Want to quickly generate a calendar populated with your semester's classes? Enter the CRNs in a comma separated list below.</p>
<form onSubmit={this.add} className="form">
<div className="input-group">
<input
id="crns"
name="crns"
type="text"
value={this.state.crns}
onChange={e => this.setState({ crns: e.target.value })}
className="form-control"
placeholder="12345,54321,..."
aria-describedby="basic-addon2"
autoComplete="off"
/>
<div className="input-group-append">
<button type="submit" className="btn btn-primary" type="button">
Populate Calendar
</button>
</div>
</div>
</form>
</div>
);
}
}
import React from 'react';
import Cart from 'src/Cart';
import Stars from 'src/Stars';
export default class Section extends React.Component {
constructor(props) {
super(props);
this.state = { inCart: Cart.includesCrn(this.props.crn) };
}
onClick = e => {
e.stopPropagation();
console.log(e.target.tagName);
if (e.target.tagName === 'A') return;
Cart.toggleCrn(this.props.crn);
this.setState({ inCart: Cart.includesCrn(this.props.crn) });
this.props.onClick && this.props.onClick(this.props.crn);
};
render() {
const { name, title, crn, instructor_name, instructor_url, teaching_rating, course_rating, location, days, start_time, end_time } = this.props;
const { inCart } = this.state;
const percent = teaching_rating ? <Stars percent={(teaching_rating[0] / 5) * 100} /> : null;
const remove = (
<span className="float-right text-center add-remove-btn" style={inCart ? {} : { display: 'none' }}>
<i id="icon" className="fas fa-minus" />
<br />
<span className="text">Remove</span>
</span>
);
const add = (
<span className="float-right text-center add-remove-btn" style={inCart ? { display: 'none' } : {}}>
<i id="icon" className="fas fa-plus" />
<br />
<span className="text">Add</span>
</span>
);
return (
<li className="list-group-item section-item" onClick={this.onClick}>
<p>
<b>{name}</b>: {title}{' '}
<em>
(#
{crn})
</em>
</p>
{remove}
{add}
<i className="fas fa-chalkboard-teacher" /> <a href={instructor_url}>{instructor_name}</a> {percent}
<br />
<i className="fas fa-map-marker-alt" /> {location} <br />
<i className="fas fa-clock" /> {days}, {start_time} - {end_time} <br />
</li>
);
}
}
import React from 'react';
import Chevron from 'src/Chevron';
import Section from 'src/Section';
export default class SectionList extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
{this.props.expandable ? <Chevron open={this.props.expanded} /> : null}
{this.props.expanded ? (
<div className="d-flex list-group list-group-flush sections">
{this.props.sections.map(section => (
<Section key={section.id} onClick={this.props.onClick} {...section} />
))}
</div>
) : (
<div />
)}
</div>
);
}
}
import React from 'react';
export default class Stars extends React.Component {
render() {
return (
<div className="star-rating">
<div className="back-stars">
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<i className="fas fa-star" aria-hidden="true" />
<div className="front-stars" style={{ width: `${this.props.percent}%` }}>
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
<i className="fa fa-star" aria-hidden="true" />
</div>
</div>
</div>
);
}
}
//import '@babel/polyfill';
class Cart {
constructor() {
this.isOpen = false;
this._courses = [];
const cartData = document.getElementById('cart-data');
if (cartData) {
this._courses = JSON.parse(cartData.dataset.cart);
}
document.addEventListener('DOMContentLoaded', () => (document.getElementById('cart-counter').innerText = this.crns.length));
}
_parseData() {
const cartData = document.getElementById('cart-data');
if (cartData) {
this._courses = JSON.parse(cartData.dataset.cart);
}
get crns() {
const crnString = localStorage.getItem('crns');
if (!crnString) return [];
return JSON.parse(crnString);
}
toggle() {
const list = document.getElementById('cart');
const icon = document.getElementById('schedule-icon');
if (this.isOpen) {
list.style.display = 'none';
icon.style.color = 'black';
} else {
list.style.display = 'block';
icon.style.color = 'green';
}
this.isOpen = !this.isOpen;
set crns(crnList) {
localStorage.setItem('crns', JSON.stringify(crnList));
document.getElementById('cart-counter').innerText = crnList.length;
}
set courses(courses) {
this._courses = courses;
for (const courseId in this._courses) {
if (this._courses[courseId].length === 0) delete this._courses[courseId];
addCrn(crn) {
if (!this.includesCrn(crn)) {
this.crns = [...this.crns, crn];
}
document.getElementById('cart-counter').innerText = Object.keys(this._courses).length;
}
async toggleSection(section) {
const resp = await fetch(`/sessions/cart?&crn=${section.crn}`, {
cache: 'no-store',
credentials: 'same-origin',
});
const json = await resp.json();
this.courses = json;
}
includesSection(obj) {
for (const key in this._courses) {
const list = this._courses[key];
if (list.includes(obj.crn)) return true;
toggleCrn(crn) {
if (!this.includesCrn(crn)) {
this.crns = [...this.crns, crn];
} else {
this.crns = this.crns.filter(c => c != crn);
}
}
return false;
includesCrn(crn) {
return this.crns.filter(c => c == crn).length > 0;
}
}
const cart = new Cart();
document.addEventListener('DOMContentLoaded', () => cart._parseData());
export default cart;
export default new Cart();
......@@ -29,5 +29,5 @@
</div>
<%= javascript_pack_tag 'search' %>
<%= javascript_pack_tag 'instructor' %>
<%= stylesheet_link_tag 'search' %>
<div class="jumbotron text-center">
<h1>SRCT Schedules</h1>
<h1><i class="fas fa-calendar-alt"></i>&nbsp;SRCT Schedules</h1>
<p class="lead">Build, share, and export your schedule. Search for classes and professors.</p>
<hr>
<p>
......@@ -36,24 +36,6 @@
</div>
</div>
<div id="quick-add" />
<h3 class="quick-add-header">Quick add</h3>
<p>Want to quickly generate a calendar populated with your semester's classes? Enter the CRNs in a comma separated list below.</p>
<form action="/sessions/add_bulk" class="form">
<div class="input-group">
<input
id="crns"
name="crns"
type="text"
class="form-control"
placeholder="12345,54321,..."
aria-describedby="basic-addon2"
autocomplete="off"
>
<div class="input-group-append">
<button type="submit" class="btn btn-primary" type="button">