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);
};
This diff is collapsed.
/**
* 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;
font-size: 10pt;
}
}
#cart {
display: none;
}
#cart-button {
color: black;
}
#cart-button:hover {
transition: 0.15s;
color: green;
}
......@@ -2,7 +2,11 @@
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
.section-item.selected {
background-color: lightgreen;
}
#add-course-btn:hover {
background-color: rgba(0,0,0,0.2);
.section-item:hover {
transition: 0.15s;
background-color: lightgray;
}
......@@ -2,3 +2,11 @@
background-color: white;
padding: 16px;
}
.section-item.selected {
background-color: white;
}
.section-item.selected:hover {
background-color: red;
}
// Place all the styles related to the search controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
.section-item:hover {
transition: 0.15s;
background-color: lightgray;
}
.section-item.selected {
transition: 0.15s;
background-color: lightgreen;
}
.section-item.selected:hover {
transition: 0.15s;
background-color: rgba(255, 0, 0, 0.6);
}
class CourseListingsController < ApplicationController
class API::CourseListingsController < ApplicationController
resource_description do
short 'Working with courses and associated sections'
end
......@@ -8,7 +8,7 @@ class CourseListingsController < ApplicationController
param :number, Integer, desc: 'Course number, e.g. "112"'
def index
# Make a separate list so that we can include sections
@courses = CourseListingsHelper::CourseListing.wrap(Course.fetch(params).all)
@courses = API::CourseListingsHelper::CourseListing.wrap(Course.fetch(params).all)
render json: @courses
end
......
# Contains all actions having to do with CourseSections.
# This is a nested controller -- see +config/routes.rb+ for details
class CourseSectionsController < ApplicationController
class API::CourseSectionsController < ApplicationController
resource_description do
short 'Working with course sections, e.g. CS 112 001'
end
......
# Contains all actions having to do with Courses.
class API::CoursesController < ApplicationController
resource_description do
short 'Working with courses, e.g. CS 112'
end
api :GET, '/courses', "Get a list of courses."
param :subject, String, desc: 'Course subject, e.g. "CS" or "ACCT"'
param :course_number, Integer, desc: 'Course number, e.g. "112"'
def index
@courses = Course.fetch(params).all
render json: @courses
end
api :GET, '/courses/:id', "Get a list of all course sections for the course with the given id."
param :id, :number, desc: 'Course ID', required: true
def show
@sections = CourseSection.where(course_id: params[:id]).all
render json: @sections
end
end
class API::SchedulesController < ApplicationController
resource_description do
short 'Endpoints for generating iCal files'
end
# Render an iCal file containing the schedules of all the
# course sections with the given CRNs.
api :GET, '/schedules', 'Generate an iCal file with events for the given CRNs'
param :section_ids, String, desc: 'Comma separated list of section ids to include as events in the calendar', required: true
def index
ids = params["section_ids"].split ','
@schedule = Schedule.new ids
render plain: @schedule.to_ical # render a plaintext iCal file
end
end
......@@ -4,25 +4,25 @@ class ApplicationController < ActionController::Base
before_action :set_semester, :set_cookies, :set_cart
def set_semester
@semester = if cookies.key?(:semester_id)
Semester.find_by(id: cookies[:semester_id])
else
Semester.find_by(season: 'Spring', year: '2019')
end
if params.key?(:semester_id)
cookies[:semester_id] = params[:semester_id]
@semester = Semester.find_by_id params[:semester_id]
elsif cookies[:semester_id].nil?
redirect_to(url_for(params.permit(params.keys).merge(semester_id: Semester.first.id)))
else
redirect_to(url_for(params.permit(params.keys).merge(semester_id: cookies[:semester_id])))
end
end
def set_cart
sections = cookies[:section_ids].split(',').map do |id|
CourseSection.find_by_id(id)
end
@cart = sections.group_by do |s|
s.course.id
end
@cart = JSON.parse(cookies[:cart])
@cart = @cart.reject { |id| CourseSection.find_by_id(id).nil? }
cookies[:cart] = @cart.to_json
end
def set_cookies
cookies[:crns] = "" if cookies[:crns].nil?
cookies[:section_ids] = "" if cookies[:section_ids].nil?
cookies[:cart] = "[]" if cookies[:cart].nil?
end
end
# Contains all actions having to do with Courses.
class CoursesController < ApplicationController
resource_description do
short 'Working with courses, e.g. CS 112'
end
before_action :set_course
api :GET, '/courses', "Get a list of courses."
param :subject, String, desc: 'Course subject, e.g. "CS" or "ACCT"'
param :course_number, Integer, desc: 'Course number, e.g. "112"'
def index
@courses = Course.fetch(params).all
render json: @courses
def show
@course = Course.find_by subject: @course.subject, course_number: @course.course_number, semester: @semester
end
api :GET, '/courses/:id', "Get a list of all course sections for the course with the given id."
param :id, :number, desc: 'Course ID', required: true
def show
@sections = CourseSection.where(course_id: params[:id]).all
private
render json: @sections
def set_course
@course = Course.find_by_id params[:id]
end
end
require 'icalendar'
require 'time'
# Contains functionality for generating schedules.
class SchedulesController < ApplicationController
resource_description do
short 'Endpoints for generating iCal files'
end
# Render an iCal file containing the schedules of all the
# course sections with the given CRNs.
api :GET, '/schedules', 'Generate an iCal file with events for the given CRNs'
param :crns, String, desc: 'Comma separated list of CRNs to include as events in the calendar', required: true
def index
crns = params["crns"].split ','
@schedule = Schedule.new crns
render plain: @schedule.to_ical # render a plaintext iCal file
end
DAYS = {
"M": Date.new(2019, 1, 14),
"T": Date.new(2019, 1, 15),
"W": Date.new(2019, 1, 16),
"R": Date.new(2019, 1, 17),
"F": Date.new(2019, 1, 18),
"S": Date.new(2019, 1, 19),
"U": Date.new(2019, 1, 20)
}.freeze
include SchedulesHelper
def show
all_sections = @cart.values
# schedules = []
all_sections.each_with_index do |sections, i|
end
@events = @cart.map do |_cid, sections|
s = sections.first
s.days.split('').map do |day|
formatted_date = DAYS[day.to_sym].to_s.tr('-', '')
time = Time.parse(s.start_time).strftime("%H%M%S")
endtime = Time.parse(s.end_time).strftime("%H%M%S")
valid_ids = @cart.reject { |id|
s = CourseSection.find_by_id(id)
s.nil? || s.start_time == "TBA" || s.end_time == "TBA"
}
{
title: s.name,
start: "#{formatted_date}T#{time}",
end: "#{formatted_date}T#{endtime}"
}
end
end.flatten
@all = valid_ids.map { |id| CourseSection.find_by_id id }
@events = generate_fullcalender_events(valid_ids)
end
end
class SearchController < ApplicationController
def index
results = SearchHelper::GenericItem.fetchall(params[:query], semester: @semester).group_by(&:type)
results = SearchHelper::GenericItem.fetchall(String.new(params[:query]), semester: @semester).group_by(&:type)
@instructors = results[:instructor]
@courses = results[:course]
end
......
......@@ -7,6 +7,20 @@ class SessionsController < ApplicationController
head :ok
end
def cart
section_id = params[:section_id]
if @cart.include?(section_id)
@cart.reject! { |id| section_id == id }
else
@cart << section_id
end
puts @cart.to_json
cookies[:cart] = @cart.to_json
render json: @cart.to_json
end
private
def update_cookie(sym)
......
module CourseListingsHelper
module API::CourseListingsHelper
class CourseListing
def initialize(course)
@course = course
......
module API::InstructorsHelper
end
module ApplicationHelper
def in_cart?(id)
@cart.select { |_cid, sections| sections.select { |s| s.id == id }.count.positive? }.count.positive?
end
end
module SchedulesHelper
DAYS = {
"M": Date.new(2019, 1, 14),
"T": Date.new(2019, 1, 15),
"W": Date.new(2019, 1, 16),
"R": Date.new(2019, 1, 17),
"F": Date.new(2019, 1, 18),
"S": Date.new(2019, 1, 19),
"U": Date.new(2019, 1, 20)
}.freeze
def generate_fullcalender_events(section_ids)
section_ids.map do |id|
s = CourseSection.find_by_id id
s.days.split('').map do |day|
formatted_date = DAYS[day.to_sym].to_s.tr('-', '')
time = Time.parse(s.start_time).strftime("%H%M%S")
endtime = Time.parse(s.end_time).strftime("%H%M%S")
{
title: s.name,
start: "#{formatted_date}T#{time}",
end: "#{formatted_date}T#{endtime}"
}
end
end.flatten
end
end
module SearchHelper
def in_cart?(id)
@cart.include? id.to_s
end
class GenericQueryData
attr_reader :semester
attr_reader :sort_mode
......
# Contains logic regarding the +Course+ model.
#
# TODO: Add more docs
class Course < ApplicationRecord
# Each course belongs to a +Semester+
belongs_to :semester
......@@ -12,6 +9,10 @@ class Course < ApplicationRecord
validates :subject, presence: true