Commit 68417818 authored by Zach Perkins's avatar Zach Perkins

Merge branch 'dev-v2' of https://git.gmu.edu/srct/schedules into 26-model-filters

parents 469013a3 a09028af
# Schedules
Schedules is a web app that allows students to import their class schedules into popular calendar managers. It consists of an API written in Ruby on Rails and a web client built with React.
Schedules is a web app that is written with Ruby on Rails and allows students to import their class schedules into popular calendar managers.
The project manager for Schedules is Zac Wood.
......@@ -29,9 +29,9 @@ the SRCT code respository, with SSH.
Run `cd schedules/` to enter the cloned directory
## Setting up API
## Setting up Project
Execute `cd schedules_api/` to enter the API directory.
Execute `cd schedules/` to enter the Project directory.
### Install dependencies
To install the project dependencies, run the `bundle install` command.
......@@ -41,32 +41,20 @@ To populate your local database, run `rake db:migrate` and `rake db:seed`. This
**NOTE:** Sometimes Patriot Web doesn't appriciate being parsed. If you're having problems,
please let us know in [Slack](https://srct.slack.com/)!
## Setting up client
### Install dependencies
To install the React client's dependencies, run the `yarn` command from the `/schedules_web` directory.
## Development servers
While developing for schedules, it is useful to have development servers for both the React client and the Ruby on Rails API running.
While developing for schedules, it is useful to have development servers running.
### API
To start the API, run the `rails server` command in the `/schedules_api` directory. The API should now be accessible from `localhost:3000`
To start the Project, run the `rails server` command in the `/schedules` directory. The website should now be accessible from `localhost:3000`
### Client
To start the development server for the React client, run the `yarn start` command from the `/schedules_web` directory. The client should now be available from `localhost:8080`.
## Testing
Before you make a commit, you should ensure you new code passes the project's tests.
It is recommended that you write tests for any new code you add, but this is not required.
### API
To run the API's tests, run the command `rails test` from the `schedules_api` directory.
### Client
To run the client's test, run the `yarn test` command from the `schedules_web` directory.
To run the Project's tests, run the command `rails test` from the `schedules` directory.
## Opening issues
......@@ -80,4 +68,4 @@ A great tool for making sure your code meets the project's style is [RuboCop](ht
gem install rubocop
Then, when inside the `/schedules_api/` directory, you can run the command `rubocop` to see where your style does not match the project's.
Then, when inside the `/schedules/` directory, you can run the command `rubocop` to see where your style does not match the project's.
class SectionCard {
constructor(section) {
this._html = `
<li id="section-${section.crn}" class="list-group-item schedule-section-card" onclick="removeFromSchedule(this)">
<span style="float:left"><b class="subj">${section.name}</b>: ${section.title}</span>
<span style="float:right"><i class="fas fa-map-marker-alt"></i> ${section.location} </span>
<div style="clear: both"></div>
<span style="float:left"><i class="fas fa-chalkboard-teacher"></i> TODO </span>
<span style="float:right"><i class="fas fa-clock"></i> ${section.days}, ${section.start_time}-${section.end_time} </span>
<div style="clear: both"></div>
</li>`;
}
}
......@@ -22,3 +22,17 @@ const elementFromString = string => {
const html = new DOMParser().parseFromString(string, 'text/html');
return html.body.firstChild;
};
document.addEventListener('DOMContentLoaded', () => {
this.schedule = new Schedule();
});
/** Loads FontAwesome icons on load; fixes weird flickering */
document.addEventListener('turbolinks:load', () => {
FontAwesome.dom.i2svg();
});
const setSemester = async select => {
const resp = await fetch(`/sessions/update?semester_id=${select.value}`);
location.reload(true);
};
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
class Schedule {
constructor() {
this.isOpen = false;
this._ids = Array.from(document.getElementById('schedule').children).map(e => e.dataset.crn);
}
get ids() {
return this._ids;
}
set ids(ids) {
this._ids = ids;
document.getElementById('course-counter').innerText = ids.length;
fetch('/sessions/update?crns=' + ids.join(','), { cache: 'no-store' });
}
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;
}
addToSchedule(section) {
if (this.ids.includes(section.dataset.crn)) return;
this.ids = [...this.ids, section.dataset.crn];
section.classList.remove('section-item');
section.classList.remove('selected');
section.classList.add('schedule-section-card');
section.onclick = () => removeFromSchedule(section);
document.getElementById('schedule').appendChild(section);
}
removeFromSchedule(id) {
const cart = document.getElementById('schedule');
const section = cart.querySelector(`#section-${id}`);
cart.removeChild(section);
this.ids = this.ids.filter(_id => _id != id);
}
async downloadIcs() {
const cal = await fetch(`/api/schedules?crns=${this.ids.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.ids.join(',')}`;
window.open(url, '_self');
}
}
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
class Schedule {
constructor() {
this.isOpen = false;
this._ids = Array.from(document.getElementById('schedule').children).map(e => e.dataset.crn);
}
get ids() {
return this._ids;
}
set ids(ids) {
this._ids = ids;
document.getElementById('course-counter').innerText = ids.length;
fetch('/search/update?ids=' + ids.join(','), { cache: 'no-store' });
}
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;
}
addToSchedule(section) {
if (this.ids.includes(section.dataset.crn)) return;
this.ids = [...this.ids, section.dataset.crn];
section.classList.remove('section-item');
const sectionWithCrn = crn => document.getElementById('search-list').querySelector(`[data-crn="${crn}"]`);
/**
* Either adds or removes a section from the schedule depending on
* if it is currently in the schedule.
*/
const addOrRemoveFromSchedule = (event, section) => {
if (this.schedule.ids.includes(section.dataset.crn)) {
this.schedule.removeFromSchedule(section.dataset.crn);
section.classList.remove('selected');
section.classList.add('schedule-section-card');
section.onclick = () => removeFromSchedule(section);
document.getElementById('schedule').appendChild(section);
}
removeFromSchedule(id) {
const cart = document.getElementById('schedule');
const section = cart.querySelector(`#section-${id}`);
cart.removeChild(section);
this.ids = this.ids.filter(_id => _id != id);
}
_constructSectionCard(section) {
const str = `
<li id="section-${section.crn}" class="list-group-item schedule-section-card" onclick="removeFromSchedule(this)">
<span style="float:left"><b class="subj">${section.name}</b>: ${section.title}</span>
<span style="float:right"><i class="fas fa-map-marker-alt"></i> ${section.location} </span>
<div style="clear: both"></div>
<span style="float:left"><i class="fas fa-chalkboard-teacher"></i> TODO </span>
<span style="float:right"><i class="fas fa-clock"></i> ${section.days}, ${section.start_time}-${section.end_time} </span>
<div style="clear: both"></div>
</li>`;
return elementFromString(str);
}
}
class Search {
sectionWithCrn(crn) {
return document.getElementById('search-list').querySelector(`[data-crn="${crn}"]`);
} else {
this.schedule.addToSchedule(section.cloneNode(true));
section.classList.add('selected');
}
}
const toggleSchedule = () => this.schedule.toggle();
const addToSchedule = (event, section) => {
section.classList.add('selected');
this.schedule.addToSchedule(section.cloneNode(true));
event.stopPropagation();
};
/**
* Removes a given section from the schedule
* @param {Node} DOM Node of the Section in the schedule
*/
const removeFromSchedule = section => {
this.search.sectionWithCrn(section.dataset.crn).classList.remove('selected');
this.schedule.removeFromSchedule(section.id.split('-')[1]);
const sectionInSearch = sectionWithCrn(section.dataset.crn);
if (sectionInSearch) {
sectionInSearch.classList.remove('selected');
}
this.schedule.removeFromSchedule(section.dataset.crn);
};
/**
* Toggles the display of the schedule
*/
const toggleSections = course => {
const sections = course.querySelector('#sections');
if (sections.style.display === 'block') {
......@@ -99,18 +43,10 @@ const toggleSections = course => {
}
};
/**
* 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.schedule.ids.join(',')}`;
};
const downloadIcs = async () => {
const cal = await fetch(`/api/schedules?crns=${this.schedule.ids.join(',')}`);
const text = await cal.text();
var blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
saveAs(blob, 'test.ics');
};
const addToSystemCalendar = async () => {
const url = `webcal://${window.location.hostname}/api/schedule?crns=${this.schedule.ids.join(',')}`;
window.open(url, '_self');
};
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.
......@@ -14,7 +14,88 @@
*= require_self
*/
// @import "bootstrap";
body {
background-color: #E4E4E4;
}
// @import "font-awesome-sprockets";
// @import "font-awesome";
.card {
margin-bottom: 12px;
background-color: white;
border-radius: 8px;
box-shadow: 0 0 5px rgba(0,0,0,0.2);
transition: 0.3s;
}
/* On mouse-over, add a deeper shadow */
.card:hover {
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;
}
.align-left {
display: flex;
justify-content: flex-start;
align-items: center;
}
.align-center {
display: flex;
justify-content: center;
align-items: center;
}
.align-right {
display: flex;
justify-content: flex-end;
align-items: center;
}
#navbar {
margin-top: 8px;
margin-bottom: 48px;
}
#logo {
font-size: 24pt;
color: black;
}
.form-control:focus {
border-color: transparent;
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.075) inset, 0px 0px 0px rgba(0, 0, 255, 0.5);
}
#cart {
display: none;
}
.card .small {
margin-bottom: 16px;
padding: 12px;
}
// Place all the styles related to the instructors controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
// 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/
body {
background-color: #E4E4E4;
}
// .container {
// margin: auto;
// width: 100%;
// }
.card {
margin-bottom: 12px;
background-color: white;
border-radius: 8px;
box-shadow: 0 0 5px rgba(0,0,0,0.2);
transition: 0.3s;
}
/* On mouse-over, add a deeper shadow */
.card:hover {
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;
}
.schedule-section-card:hover {
transition: 0.15s;
background-color: red;
}
.align-vertical {
display: flex;
align-items: center;
}
.align-left {
display: flex;
justify-content: flex-start;
align-items: center;
}
.align-center {
display: flex;
justify-content: center;
align-items: center;
}
.align-right {
display: flex;
justify-content: flex-end;
align-items: center;
}
#navbar {
margin-top: 8px;
margin-bottom: 48px;
}
#logo {
font-size: 24pt;
color: black;
}
.form-control:focus {
border-color: transparent;
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.075) inset, 0px 0px 0px rgba(0, 0, 255, 0.5);
}
#cart {
display: none;
}
.card .small {
margin-bottom: 16px;
padding: 12px;
}
// Place all the styles related to the sessions controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
# Configures the application.
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
before_action :set_cookies, :set_cart
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
end
def set_cart
@cart = cookies[:ids].split(',').map do |crn|
CourseSection.find_by_crn crn
@cart = cookies[:crns].split(',').map do |crn|
s = CourseSection.find_by_crn(crn)
s if s.course.semester == @semester
end
@cart.compact!
end
def set_cookies
cookies[:ids] = "" if cookies[:ids].nil?
cookies[:crns] = "" if cookies[:crns].nil?
end
end
class InstructorsController < ApplicationController
before_action :set_instructor, only: [:show]
def index
@instructors = Instructor.all
end
def show
sections = CourseSection.where instructor: @instructor
sections = sections.select do |s|
s.course.semester == @semester
end
# TODO: move this to a model somewhere
@courses = [].to_set
sections.each do |s|
@courses.add s.course
end
end
private
def set_instructor
@instructor = Instructor.find_by_id params[:id]
end
end
class SearchController < ApplicationController
def index
@results = SearchHelper::GenericItem.fetchall(params[:query])
end
def update
cookies[:ids] = params[:ids]
@results = SearchHelper::GenericItem.fetchall(query_string: params[:query], semester: @semester)
end
end
class SessionsController < ApplicationController
def update
update_cookie :crns
update_cookie :semester_id
head :ok
end
private
def update_cookie(sym)
cookies[sym] = params[sym] unless params[sym].nil?
end
end
module SearchHelper
class GenericQueryData
attr_reader :semester
attr_reader :sort_mode
attr_reader :search_string
def initialize(search_string, sort_mode, semester)
@semester = semester
@sort_mode = sort_mode
@search_string = search_string
end
end
class GenericItem
attr_reader :data
attr_reader :type
......@@ -8,23 +20,31 @@ module SearchHelper
@data = data
end
def self.fetchall(query_string, sort_mode=:auto)
def self.fetchall(search_string, sort_mode=:auto, semester=:fall2018)
query_data = GenericQueryData.new(search_string, sort_mode, semester)
models = []
models += fetch_instructors query_string
models += fetch_courses query_string
models += fetch_instructors query_data
models += fetch_courses query_data
build_list(models)
end
def self.fetch_instructors(query_string)
Instructor.from_name(Instructor.select("*"), query_string).all
def self.fetch_instructors(query_data)
Instructor.from_name(Instructor.select("instructors.*, COUNT(course_sections.id) AS section_count"), query_data.search_string)
.left_outer_joins(:course_sections)
.group("instructors.id")
.where("course_sections.semester = ?", query_data.semester)
.having("section_count > 0")
.all
end
def self.fetch_courses(query_string)
def self.fetch_courses(query_data)
query_string = query_data.search_string
query_string.upcase!
CourseReplacementHelper.replace!(query_string)
base_query = Course.select("courses.*, count(course_sections.id) AS section_count")
.left_outer_joins(:course_sections)
.having("section_count > 0")
.where("course_sections.semester = ?", query_data.semester)
.group("courses.id")
subj = nil
......
......@@ -59,4 +59,4 @@ class Course < ApplicationRecord
query
end
end
\ No newline at end of file
end