Commit 799194f9 authored by Zac Wood's avatar Zac Wood

Merge branch 'v2-complete' into 'dev-v2'

V2 complete

See merge request !35
parents fa9ffff2 2a340f08
Pipeline #3375 passed with stages
in 13 minutes and 20 seconds
......@@ -13,3 +13,6 @@ Style/SymbolArray:
Metrics/BlockLength:
Enabled: false
Style/ClassAndModuleChildren:
Enabled: false
......@@ -13,10 +13,9 @@ gem 'sqlite3'
gem 'puma', '~> 3.7'
# Use SCSS for stylesheets
gem 'sass-rails', '~> 5.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.5'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use ActiveModel has_secure_password
......
// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's
// vendor/assets/javascripts directory can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file. JavaScript code in this file should be added after the last require_* statement.
//
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require rails-ujs
//= require turbolinks
//= require FileSaver
//= require_tree .
// require jquery3
// require popper
// require bootstrap-sprockets
//= require cart
const elementFromString = string => {
const html = new DOMParser().parseFromString(string, 'text/html');
......@@ -25,14 +8,16 @@ const elementFromString = string => {
document.addEventListener('DOMContentLoaded', () => {
this.cart = new Cart();
initGlobalListeners();
});
const setSemester = async select => {
const resp = await fetch(`/sessions/update?semester_id=${select.value}`);
location.reload(true);
const url = new URL(window.location.href);
url.searchParams.set('semester_id', select.value);
window.open(url.toString(), '_self');
};
/** Loads FontAwesome icons on load; fixes weird flickering */
document.addEventListener('turbolinks:load', () => {
FontAwesome.dom.i2svg();
});
const initGlobalListeners = () => {
const semesterSelect = document.getElementById('semester-select');
semesterSelect.onchange = () => setSemester(semesterSelect);
};
class Cart {
constructor() {
this.isOpen = false;
this._courses = {}; // {title, id, sections: {id, crn}}
const cartData = document.querySelector('#cart-data');
const courses = Array.from(cartData.content.children);
for (const course of courses) {
const { id, title } = course.dataset;
const sections = Array.from(course.children).map(node => ({ ...node.dataset }));
this._courses[id] = { id, title, sections };
}
document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
this._courses = [];
this._ids = Array.from(document.getElementById('cart-courses').children).map(e => e.dataset.crn);
}
get crns() {
return Object.keys(this._courses)
.map(cid => this._courses[cid].sections.map(s => s.crn))
.reduce((prev, curr) => [...prev, ...curr], []);
}
get ids() {
return Object.keys(this._courses)
.map(cid => this._courses[cid].sections.map(s => s.id))
.reduce((prev, curr) => [...prev, ...curr], []);
const cartData = document.getElementById('cart-data');
if (cartData) {
this._courses = JSON.parse(cartData.dataset.cart);
}
}
toggle() {
......@@ -42,111 +24,179 @@ class Cart {
this.isOpen = !this.isOpen;
}
addCourse(course) {
this._courses[course.id] = course;
const courseList = document.getElementById('cart-courses');
const courseNode = courseList.querySelector(`#schedule-${course.id}`);
const newNode = this._constructCourseNode(course);
if (courseNode !== null) courseList.replaceChild(newNode, courseNode);
else courseList.appendChild(newNode);
document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
}
removeCourse(id) {
const sectionIds = this._courses[id].sections.map(s => s.id);
for (const sectionId of sectionIds) {
const sectionCard = document.querySelector(`#section-${sectionId}`);
sectionCard && sectionCard.classList.remove('selected');
}
delete this._courses[id];
const courseList = document.getElementById('cart-courses');
const current = courseList.querySelector(`#schedule-${id}`);
courseList.removeChild(current);
document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
}
courseContainingSection(id) {
set courses(courses) {
this._courses = courses;
for (const courseId in this._courses) {
const course = this._courses[courseId];
for (const section of course.sections) {
if (section.id == id) return course;
}
if (this._courses[courseId].length === 0) delete this._courses[courseId];
}
return undefined;
document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
}
includesSection(id) {
return !!this.courseContainingSection(id);
async addSection(section) {
const resp = await fetch(`/sessions/cart?&section_id=${section.id}`, { cache: 'no-store' });
const json = await resp.json();
this.courses = json;
}
// section: { id, crn }
addSection(section) {
const course = this._courses[section.cid];
if (course) {
course.sections.push(section);
const courseNode = document.getElementById(`#schedule-${course.id}`);
const crnList = courseNode.querySelector('.crns');
crnList.innerText = course.sections.map(s => `#${s.crn}`);
fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
} else {
const courseCard = document.getElementById(`course-${section.cid}`);
const title = courseCard.querySelector('.title').innerText;
this.addCourse({ title, id: section.cid, sections: [section] });
includesSection(obj) {
for (const key in this._courses) {
const list = this._courses[key];
if (list.includes(obj.id)) return true;
}
}
removeSection(section) {
const course = this.courseContainingSection(section.id);
course.sections = course.sections.filter(s => s.id !== section.id);
const schedule = document.querySelector('#cart-courses');
const courseNode = schedule.querySelector(`#schedule-${course.id}`);
const crnList = courseNode.querySelector('.crns');
if (course.sections.length === 0) {
this.removeCourse(section.cid);
} else {
crnList.innerText = course.sections.map(s => `#${s.crn}`);
}
fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
}
async downloadIcs() {
const cal = await fetch(`/api/schedules?crns=${this.crns.join(',')}`);
const text = await cal.text();
var blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
saveAs(blob, 'test.ics');
}
async addToSystemCalendar() {
const url = `webcal://${window.location.hostname}/api/schedule?crns=${this.crns.join(',')}`;
window.open(url, '_self');
}
_constructCourseNode(course) {
let html = `<li id="schedule-${course.id}" class="list-group-item schedule-section-card" onclick="removeCourse(${course.id})">`;
html += `<div style="display: flex; justify-content: space-between;">`;
html += `<b style="min-width: 15%">${course.title}</b>`;
html += `<span class="crns" style="color: gray; font-size: 10pt;">`;
html += course.sections.map(s => `#${s.crn}`).join(', ');
html += `</span>`;
html += `</div>`;
html += `</li>`;
return elementFromString(html);
return false;
}
}
const removeCourse = id => {
this.cart.removeCourse(id);
};
// class Cart {
// constructor() {
// this.isOpen = false;
// this._courses = {}; {title, id, sections: {id, crn}}
// const cartData = document.getElementById('cart-data');
// const courses = Array.from(cartData.content.children);
// for (const course of courses) {
// const { id, title } = course.dataset;
// const sections = Array.from(course.children).map(node => ({ ...node.dataset }));
// this._courses[id] = { id, title, sections };
// }
// document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
// this._ids = Array.from(document.getElementById('cart-courses').children).map(e => e.dataset.crn);
// }
// get crns() {
// return Object.keys(this._courses)
// .map(cid => this._courses[cid].sections.map(s => s.crn))
// .reduce((prev, curr) => [...prev, ...curr], []);
// }
// get ids() {
// return Object.keys(this._courses)
// .map(cid => this._courses[cid].sections.map(s => s.id))
// .reduce((prev, curr) => [...prev, ...curr], []);
// }
// 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;
// }
// addCourse(course) {
// this._courses[course.id] = course;
// const courseList = document.getElementById('cart-courses');
// const courseNode = courseList.querySelector(`#schedule-${course.id}`);
// const newNode = this._constructCourseNode(course);
// if (courseNode !== null) courseList.replaceChild(newNode, courseNode);
// else courseList.appendChild(newNode);
// document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
// fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
// }
// removeCourse(id) {
// const sectionIds = this._courses[id].sections.map(s => s.id);
// for (const sectionId of sectionIds) {
// const sectionCard = document.querySelector(`#section-${sectionId}`);
// sectionCard && sectionCard.classList.remove('selected');
// }
// delete this._courses[id];
// const courseList = document.getElementById('cart-courses');
// const current = courseList.querySelector(`#schedule-${id}`);
// courseList.removeChild(current);
// document.getElementById('course-counter').innerText = Object.keys(this._courses).length;
// fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
// }
// courseContainingSection(id) {
// for (const courseId in this._courses) {
// const course = this._courses[courseId];
// for (const section of course.sections) {
// if (section.id == id) return course;
// }
// }
// return undefined;
// }
// includesSection(id) {
// return !!this.courseContainingSection(id);
// }
// section: { id, crn }
// addSection(section) {
// const course = this._courses[section.cid];
// if (course) {
// course.sections.push(section);
// const courseNode = document.getElementById(`schedule-${course.id}`);
// const crnList = courseNode.querySelector('.crns');
// crnList.innerText = course.sections.map(s => `#${s.crn}`);
// fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
// } else {
// const courseCard = document.getElementById(`course-${section.cid}`);
// const title = courseCard.querySelector('.title').innerText;
// this.addCourse({ title, id: section.cid, sections: [section] });
// }
// }
// removeSection(section) {
// const course = this.courseContainingSection(section.id);
// course.sections = course.sections.filter(s => s.id !== section.id);
// const schedule = document.querySelector('#cart-courses');
// const courseNode = schedule.querySelector(`#schedule-${course.id}`);
// const crnList = courseNode.querySelector('.crns');
// if (course.sections.length === 0) {
// this.removeCourse(section.cid);
// } else {
// crnList.innerText = course.sections.map(s => `#${s.crn}`);
// }
// fetch(`/sessions/update?section_ids=${this.ids.join(',')}`, { cache: 'no-store' });
// }
// async downloadIcs() {
// const cal = await fetch(`/api/schedules?crns=${this.crns.join(',')}`);
// const text = await cal.text();
// var blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
// saveAs(blob, 'test.ics');
// }
// async addToSystemCalendar() {
// const url = `webcal:${window.location.hostname}/api/schedule?crns=${this.crns.join(',')}`;
// window.open(url, '_self');
// }
// _constructCourseNode(course) {
// let html = `<li id="schedule-${course.id}" class="list-group-item schedule-section-card" onclick="removeCourse(${course.id})">`;
// html += `<div style="display: flex; justify-content: space-between;">`;
// html += `<b style="min-width: 15%">${course.title}</b>`;
// html += `<span class="crns" style="color: gray; font-size: 10pt;">`;
// html += course.sections.map(s => `#${s.crn}`).join(', ');
// html += `</span>`;
// html += `</div>`;
// html += `</li>`;
// return elementFromString(html);
// }
// }
// const removeCourse = id => {
// this.cart.removeCourse(id);
// };
/**
* 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 };
await this.cart.addSection(section);
if (this.cart.includesSection(section)) {
sectionNode.classList.add('selected');
} else {
sectionNode.classList.remove('selected');
}
};
const initListeners = () => {
const items = Array.from(document.querySelectorAll('.section-item'));
items.forEach(item => (item.onclick = e => addOrRemoveFromCart(e, item)));
};
document.addEventListener('DOMContentLoaded', initListeners);
{
"compilerOptions": {
"lib": ["es2015", "dom"]
}
}
......@@ -3,12 +3,49 @@ document.addEventListener('DOMContentLoaded', () => {
if (eventsTemplate) {
const eventsJSON = eventsTemplate.dataset.events;
const events = JSON.parse(eventsJSON);
console.log(events);
window.events = events;
$('#calendar').fullCalendar({
defaultDate: new Date(2019, 0, 14),
defaultView: 'agendaWeek',
header: false,
events: events,
events: renderEvents,
});
}
initListeners();
});
const renderEvents = (start, end, timezone, callback) => {
callback(window.events);
};
const remove = async item => {
await window.cart.addSection({ ...item.dataset });
location.reload(true);
};
/**
* Generates a URL for the current sections in the schedule
* and sets the link in the modal to it.
*/
const setUrlInModal = () => {
document.getElementById('calendar-link').innerText = `${window.location.protocol}//${window.location.hostname}/api/schedules?section_ids=${window.cart._courses.join(',')}`;
};
const downloadIcs = async () => {
const response = await fetch(`http://localhost:3000/api/schedules?section_ids=${window.cart._courses.join(',')}`);
const text = await response.text();
const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
saveAs(blob, 'GMU Schedule.ics');
};
const addToSystemCalendar = () => {};
const initListeners = () => {
const items = Array.from(document.querySelectorAll('.section-item'));
items.forEach(item => (item.onclick = () => remove(item)));
document.getElementById('open-modal-btn').onclick = setUrlInModal;
document.getElementById('download-ics').onclick = downloadIcs;
document.getElementById('add-to-system').onclick = addToSystemCalendar;
};
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
const sectionWithCrn = crn => document.getElementById('search-list').querySelector(`[data-crn="${crn}"]`);
const addCourse = (event, id) => {
const courseCard = document.getElementById(`course-${id}`);
const title = courseCard.querySelector('.title').innerText;
const sectionsItems = Array.from(courseCard.querySelectorAll('li'));
const sections = sectionsItems.map(li => ({ ...li.dataset }));
this.cart.addCourse({ title, id, sections });
sectionsItems.forEach(s => s.classList.add('selected'));
event.stopPropagation();
};
/**
* Either adds or removes a section from the cart depending on
* if it is currently in the cart.
*/
const addOrRemoveFromCart = (event, sectionNode) => {
const addOrRemoveFromCart = async (event, sectionNode) => {
event && event.stopPropagation();
const section = { ...sectionNode.dataset };
if (this.cart.includesSection(section.id)) {
this.cart.removeSection(section);
sectionNode.classList.remove('selected');
} else {
this.cart.addSection(section);
await this.cart.addSection(section);
if (this.cart.includesSection(section)) {
sectionNode.classList.add('selected');
} else {
sectionNode.classList.remove('selected');
}
event.stopPropagation();
};
/**
* Removes a given section from the cart
* @param {Node} DOM Node of the Section in the cart
*/
const removeFromCart = section => {
const sectionInSearch = sectionWithCrn(section.dataset.crn);
if (sectionInSearch) {
sectionInSearch.classList.remove('selected');
}
this.cart.removeFromSchedule(section.dataset.crn);
};
/**
......@@ -49,18 +22,28 @@ const removeFromCart = section => {
*/
const toggleSections = course => {
const sections = course.querySelector('.sections');
console.log(sections);
const chev = $(course.querySelector('#course-chevron'));
const label = course.querySelector('#chevron-label');
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';
}
};
/**
* Generates a webcal:// URL for the current sections in the schedule
* and sets the link in the modal to it.
*/
const setUrlInModal = () => {
document.getElementById('calendar-link').innerText = `https://${window.location.hostname}/api/schedule?crns=${this.cart.ids.join(',')}`;
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);
......@@ -10,7 +10,9 @@
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require_tree .
* require_tree .
*= require cart
*= require navbar
*= require_self
*/
......@@ -32,26 +34,24 @@ body {
display: flex;
flex-direction: column;
}
.card-body {
.attr-list {
display: flex;
flex-direction: row;
}
.attr {
.icon {
padding-right: 4px;
}
align-items: center;
display: inline-flex;
white-space: nowrap;
}
}
.attr-list {
display: flex;
flex-direction: row;
.attr {
.icon {
padding-right: 4px;
}
align-items: center;
display: inline-flex;
white-space: nowrap;
}
}
}
.unpadded {
padding: 0px;
padding: 0px;
}
/* On mouse-over, add a deeper shadow */
......@@ -59,25 +59,6 @@ body {
box-shadow: 0 0 20px rgba(0,0,0,0.4);
}
.list-group-item:hover {
transition: 0.15s;
background-color: lightgray;
}
.list-group-item.selected {
background-color: lightgreen;
}
.list-group-item.selected:hover {
transition: 0.15s;
background-color: red;
}
.schedule-section-card:hover {
transition: 0.15s;
background-color: red;
}
.align-vertical {
display: flex;
align-items: center;
......
.cart-course {
display: flex; justify-content: space-between;
.title {
min-width: 15%;
}
.crns {
color: gray;