Commit 02b56664 authored by Zac Wood's avatar Zac Wood

Add stimululs.js, turbolinks EVERYTHING

parent db5fd951
Pipeline #5117 passed with stage
in 2 minutes and 25 seconds
.vscode
schedules.code-workspace
schedules/config/test.html
schedules/db/data/last_update.txt
......@@ -20,9 +20,6 @@ gem 'uglifier'
gem 'webpacker', '~> 3.5'
# Access Ruby data from JavaScript
gem 'gon'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
......@@ -66,3 +63,6 @@ gem 'apipie-rails'
# Markdown for API docs
gem 'maruku'
# super fast page loads
gem 'turbolinks'
\ No newline at end of file
......@@ -108,7 +108,6 @@ GEM
parser (2.6.4.1)
ast (~> 2.4.0)
powerpack (0.1.2)
prettier (0.15.0)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
......@@ -206,6 +205,9 @@ GEM
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.10)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
uglifier (4.2.0)
......@@ -241,7 +243,6 @@ DEPENDENCIES
listen (>= 3.0.5, < 3.2)
maruku
nokogiri
prettier
pry
pry-doc
puma (~> 3.7)
......@@ -254,6 +255,7 @@ DEPENDENCIES
spring
spring-watcher-listen (~> 2.0.0)
sqlite3 (= 1.3.13)
turbolinks
tzinfo-data
uglifier
web-console (>= 3.3.0)
......
......@@ -32,6 +32,10 @@ h6 {
font-family: 'Open Sans', sans-serif;
}
.hidden {
display: none;
}
.hero {
margin-top: 30%;
h1 {
......
# Configures the application.
class ApplicationController < ActionController::Base
include BySemester
before_action :set_render_page
def set_render_page
@render_page = true
end
end
class CourseSectionsController < ApplicationController
def index
@render_page = false
crns = params[:crns].split(',')
@sections = crns.map { |crn| CourseSection.latest_by_crn(crn) }
@days = {
......@@ -26,6 +25,8 @@ class CourseSectionsController < ApplicationController
Time.new(a.start_time) <=> Time.new(b.start_time)
end
end
render(layout: false)
end
def show
......
# Contains functionality for generating schedules.
class SchedulesController < ApplicationController
include SchedulesHelper
def show; end
def view
@all = params[:crns]
.split(',')
.map { |crn| CourseSection.latest_by_crn(crn) }
.reject(&:nil?)
@without_online = @all.reject { |s|
s.start_time == "TBA" || s.end_time == "TBA"
}
@events = generate_fullcalender_events(@without_online)
end
def events
@cart = params[:crns].split(',')
.map { |crn| CourseSection.latest_by_crn(crn) }
.reject(&:nil?)
@without_online = @cart.reject { |s|
s.start_time == "TBA" || s.end_time == "TBA"
}
@events = generate_fullcalender_events(@without_online)
sections = @cart.map do |s|
s.serializable_hash.merge(instructor_name: s.instructor.name, instructor_url: instructor_url(s.instructor))
end
render json: { events: @events, sections: sections }
end
end
class SessionsController < ApplicationController
def cart
section_crn = params[:crn]
if @cart.include?(section_crn.to_s)
@cart.reject! { |crn| section_crn.to_s == crn.to_s }
else
@cart << section_crn
end
cookies.permanent[:cart] = @cart.to_json
render json: @cart.to_json
end
def add_bulk
crns = params[:crns].split(',')
crns.each do |crn|
s = CourseSection.latest_by_crn(crn)
next if s.nil?
@cart << crn.to_s unless @cart.include?(crn.to_s)
end
cookies.permanent[:cart] = @cart.to_json
redirect_to(schedule_path)
end
end
......@@ -8,70 +8,21 @@
import 'url-polyfill'
// Turbolinks for super fast page loads
import Turbolinks from 'turbolinks'
Turbolinks.start()
window.addEventListener('turbolinks:load', () => {
setInitialLinks()
addListeners()
document.querySelector('#count').innerText = getCart().length
})
function setInitialLinks() {
getCart().forEach(writeLink)
}
function addListeners() {
const links = Array.from(document.querySelectorAll('.add-section'))
for (const link of links) {
link.addEventListener('click', e => {
e.preventDefault()
const crn = link.dataset.crn
toggleSection(crn)
writeLink(crn)
})
}
}
function getCart() {
return JSON.parse(localStorage.getItem('cart') || '[]')
}
function toggleSection(crn) {
if (getCart().includes(crn)) {
removeSection(crn)
} else {
addSection(crn)
}
console.log(getCart())
document.querySelector('#count').innerText = getCart().length
}
function addSection(crn) {
const newCart = [...getCart(), crn]
localStorage.setItem('cart', JSON.stringify(newCart))
}
function removeSection(crn) {
const newCart = getCart().filter(c => c !== crn)
localStorage.setItem('cart', JSON.stringify(newCart))
}
// Load stimulus for application
// Schedules uses stimulus for JavaScript components, which is a much
// more lightweight solution than React, for example
//
// Read more here: https://stimulusjs.org/handbook/origin
// It's really cool.
function writeLink(crn) {
const item = document.querySelector(`[data-crn="${crn}"]`)
if (!item) return
const icon = item.querySelector('.add-remove-link a i')
const link = item.querySelector('.add-remove-link a span')
if (getCart().includes(crn)) {
link.innerText = ' Remove from cart'
icon.className = 'fas fa-minus'
} else {
link.innerText = ' Add Section to Cart'
icon.className = 'fas fa-plus'
}
}
import { Application } from 'stimulus'
import { definitionsFromContext } from 'stimulus/webpack-helpers'
const elementFromString = string => {
const html = new DOMParser().parseFromString(string, 'text/html')
return html.body.firstChild
}
const application = Application.start()
const context = require.context('src/controllers', true, /\.js$/)
application.load(definitionsFromContext(context))
import { saveAs } from 'file-saver'
const initPage = () => {
if (getCart().length != 0) {
document.getElementById('root').innerHTML = '<i class="fas fa-spinner fa-spin"></i>'
fetch(`/course_sections?crns=${getCart().join(',')}`)
.then(resp => resp.text())
.then(text => {
const tree = elementFromString(text)
const body = tree.querySelector('.page')
document.getElementById('root').innerHTML = body.innerHTML
setInitialLinks()
addListeners()
})
} else {
document.getElementById('root').innerHTML = 'Add classes to your cart to see them here!'
}
document.querySelector('#count').innerText = getCart().length
document.getElementById('add-to-system').addEventListener('click', () => {
window.open(
`webcal://${window.location.hostname}${
window.location.port === '3000' ? ':3000' : ''
}/api/schedules?crns=${getCart().join(',')}`
)
})
document.getElementById('download-ics').addEventListener('click', () => {
fetch(
`${window.location.protocol}//${window.location.hostname}${
window.location.port === '3000' ? ':3000' : ''
}/api/schedules?crns=${getCart().join(',')}`
)
.then(resp => resp.text())
.then(text => {
const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' })
saveAs(blob, 'GMU Schedule.ics')
})
})
}
window.addEventListener('DOMContentLoaded', initPage)
function setInitialLinks() {
getCart().forEach(writeLink)
}
function addListeners() {
const links = Array.from(document.querySelectorAll('.add-section'))
for (const link of links) {
link.addEventListener('click', e => {
e.preventDefault()
const crn = link.dataset.crn
toggleSection(crn)
writeLink(crn)
})
}
}
function getCart() {
return JSON.parse(localStorage.getItem('cart') || '[]')
}
function toggleSection(crn) {
if (getCart().includes(crn)) {
removeSection(crn)
} else {
addSection(crn)
}
console.log(getCart())
document.querySelector('#count').innerText = getCart().length
}
function addSection(crn) {
const newCart = [...getCart(), crn]
localStorage.setItem('cart', JSON.stringify(newCart))
}
function removeSection(crn) {
const newCart = getCart().filter(c => c !== crn)
localStorage.setItem('cart', JSON.stringify(newCart))
}
function writeLink(crn) {
const item = document.querySelectorAll(`[data-crn="${crn}"]`)
if (item.length == 0) return
item.forEach(item => {
const icon = item.querySelector('.add-remove-link a i')
const link = item.querySelector('.add-remove-link a span')
if (getCart().includes(crn)) {
link.innerText = ' Remove from cart'
icon.className = 'fas fa-minus'
} else {
link.innerText = ' Add Section to Cart'
icon.className = 'fas fa-plus'
}
})
}
const elementFromString = string => {
const html = new DOMParser().parseFromString(string, 'text/html')
return html.body
}
const subscribers = []
export function subscribe(callback) {
subscribers.push(callback)
}
export function getCart() {
return JSON.parse(localStorage.getItem('cart') || '[]').filter(val => !!val)
}
export function hasSection(crn) {
return getCart().includes(crn)
}
export function toggleSection(crn) {
if (getCart().includes(crn)) {
removeSection(crn)
} else {
addSection(crn)
}
for (const callback of subscribers) {
callback()
}
}
function addSection(crn) {
const newCart = [...getCart(), crn]
localStorage.setItem('cart', JSON.stringify(newCart))
}
function removeSection(crn) {
const newCart = getCart().filter(c => c !== crn)
localStorage.setItem('cart', JSON.stringify(newCart))
}
import { Controller } from 'stimulus'
import { getCart, subscribe } from 'src/cart'
export default class extends Controller {
static targets = ['counter']
connect() {
subscribe(() => this.draw())
this.draw()
}
draw() {
this.counterTarget.innerText = getCart().length
}
}
import { Controller } from 'stimulus'
import { getCart } from 'src/cart'
import { buildUrl, downloadIcal } from '../utils'
export default class extends Controller {
static targets = ['schedule', 'loader', 'export']
connect() {
if (getCart().length == 0) {
this.exportTarget.classList.add('hidden')
this.loaderTarget.classList.add('hidden')
this.scheduleTarget.innerHTML = 'Add courses to your cart to see them here!'
} else {
this.exportTarget.classList.remove('hidden')
this.loaderTarget.classList.remove('hidden')
this.scheduleTarget.innerHTML = ''
fetch(`/course_sections?crns=${getCart().join(',')}`)
.then(resp => resp.text())
.then(text => {
this.scheduleTarget.innerHTML = text
this.loaderTarget.classList.add('hidden')
})
}
}
downloadIcs() {
downloadIcal(buildUrl(`/api/schedules?crns=${getCart().join(',')}`), 'GMU Schedule.ics')
}
openWebcal() {
window.open(buildUrl(`/api/schedules?crns=${getCart().join(',')}`, 'webcal:'))
}
}
import { Controller } from 'stimulus'
import Turbolinks from 'turbolinks'
import { buildUrl } from '../utils'
export default class extends Controller {
static targets = ['input']
search(event) {
event.preventDefault()
Turbolinks.visit(buildUrl(`/search?query=${this.inputTarget.value}`))
}
}
import { Controller } from 'stimulus'
import { buildUrl } from '../utils'
import Turbolinks from 'turbolinks'
export default class extends Controller {
changeSemester(event) {
event.preventDefault()
const id = event.target.value
Turbolinks.visit(buildUrl(window.location.pathname + '?' + event.target.name + '=' + id))
}
}
import { Controller } from 'stimulus'
import { subscribe, toggleSection, hasSection } from 'src/cart'
export default class extends Controller {
static targets = ['icon', 'text']
connect() {
subscribe(() => this.draw())
this.draw()
}
toggle() {
toggleSection(this.crn)
}
draw() {
if (hasSection(this.crn)) {
this.iconTarget.className = 'fas fa-minus add-remove-icon'
this.textTarget.innerText = 'Remove from Cart'
} else {
this.iconTarget.className = 'fas fa-plus add-remove-icon'
this.textTarget.innerText = 'Add Section to Cart'
}
}
get crn() {
return this.data.get('crn')
}
}
export function buildUrl(url, protocol = window.location.protocol) {
const port = window.location.port === '3000' ? ':3000' : ''
return `${protocol}//${window.location.hostname}${port}${url}`
}
export function downloadIcal(url, filename) {
fetch(url)
.then(resp => resp.text())
.then(text => {
const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' })
saveAs(blob, filename)
})
}
......@@ -24,8 +24,8 @@
</div>
<div class="col-12 col-lg">
<form class="semester-select">
<select name="semester_id" class="form-control" id="semesterselect" onchange="this.form.submit()" aria-label="Semester">
<form class="semester-select" data-controller="semester-select">
<select name="semester_id" class="form-control" data-action="semester-select#changeSemester" aria-label="Semester">
<% @semesters.each do |sem| %>
<option
id="<%= sem.id %>"
......
<div class="text-center hero">
<h1><i class="fas fa-calendar-alt"></i>&nbsp;<span style="">SRCT</span> <strong style="">Schedules</strong></h1>
<form id="search-container" action="/search">
<form data-controller="search" data-action="search#search" id="search-container" action="/search">
<input
data-target="search.input"
name="query"
value="<%= params[:query] %>"
placeholder="PSYC, CS 112, 71926, Jonathan Bell, ..."/>
<button type="submit">
<i class="fas fa-search"></i>
......
......@@ -11,8 +11,8 @@
</div>
<div class="col-lg-8 col-12">
<form class="semester-select">
<select name="semester_id" class="form-control" id="semesterselect" onchange="this.form.submit()" aria-label="Semester">
<form class="semester-select" data-controller="semester-select">
<select name="semester_id" class="form-control" data-action="semester-select#changeSemester" aria-label="Semester">
<% @semesters.each do |sem| %>
<option
id="<%= sem.id %>"
......
......@@ -44,14 +44,8 @@
</head>
<body>
<% if @render_page %>
<%= render 'shared/page' do %>
<%= yield %>
<% end %>
<% else %>
<div class="page">
<%= render 'shared/page' do %>
<%= yield %>
</div>
<% end %>
<!-- Matomo -->
......
This diff is collapsed.
<% expanded = false unless defined? expanded %>
<% if defined?(@instructor) %>
<% sections = course.course_sections.where(instructor: @instructor, semester: @semester).order(:name) %>
<% else %>
<% sections = course.course_sections.where(semester: @semester).order(:name) %>
<% end %>
<div class="card course-card" id="course-<%= course.id %>">
<div class="card-header">
<div class="row">
......@@ -49,14 +41,5 @@
<i id="course-chevron" class="fas fa-chevron-down"></i>
</div>
<% end %>
<!-- List of Course Sections -->
<div class="list-group list-group-flush sections" style="display: <%= expanded ? "flex" : "none" %>">
</div>
</div>
</div>
<nav>
<span><a href="/"><i class="fas fa-calendar-alt"></i> Schedules</a></span>
<form id="search-container" action="/search" method="GET">
<input id="search-input" aria-label="Search" name="query" placeholder="PSYC, CS 112, 71926, Jonathan Bell, ..." value="<%= params[:query] %>"/>
<button aria-label="Submit" type="submit">
<i class="fas fa-search"></i>
</button>
</form>
<form data-controller="search" data-action="search#search" id="search-container" action="/search">
<input
data-target="search.input"
name="query"
value="<%= params[:query] %>"
placeholder="PSYC, CS 112, 71926, Jonathan Bell, ..."/>
<button type="submit">
<i class="fas fa-search"></i>
</button>
</form>
</nav>
\ No newline at end of file
<main class="container">
<main class="container" data-controller="application">
<%# <div id="page" class="row"> %>
<!-- Search result, List of Courses -->
<div class="col-lg-12 col-12 col-sm-12 mx-auto order-2 order-lg-0" id="search-list">
......@@ -6,8 +6,8 @@
</div>
<%# </div> %>
<a class="cart" href="/schedule" data-turbolinks="false">
<span id="count">0</span>
<a data-controller="cart" class="cart" href="/schedule">
<span id="count" data-target="cart.counter">0</span>
<i class="fas fa-shopping-cart"></i>
</a>
......
<li id="section-<%= section.id %>" data-crn="<%= "#{section.crn}" %>" class="list-group-item card section-item">
<li id="section-<%= section.id %>"
data-crn="<%= "#{section.crn}" %>"
class="list-group-item card section-item"
>
<p><strong class="subj"><%= "#{section.name}" %></strong>: <%= section.title %> <em><%= "(##{section.crn})"%></em></p>
<div class="stats">
......@@ -27,9 +31,13 @@
<% end %>
<div class="add-remove-link">
<a data-crn="<%= "#{section.crn}" %>" class="underline add-section">
<i class="fas fa-plus add-remove-icon"></i>
<span>Add Section to Cart</span>
<a data-controller="toggle-section"
data-toggle-section-crn="<%= "#{section.crn}" %>"
class="underline add-section"
data-action="click->toggle-section#toggle"
>
<i data-target="toggle-section.icon" class="fas fa-plus add-remove-icon"></i>
<span data-target="toggle-section.text">Add Section to Cart</span>
</a>
</div>
</li>
{
"name": "schedules",
"private": true,
"dependencies": {
"@babel/polyfill": "^7.0.0",
"@prettier/plugin-ruby": "^0.15.0",
"@rails/webpacker": "3.5",
"babel-preset-react": "^6.24.1",
"file-saver": "^2.0.0",
"fullcalendar": "^3.9.0",
"html2canvas": "^1.0.0-alpha.12",
"jquery": "^3.3.1",
"moment": "^2.23.0",
"prop-types": "^15.7.2",
"react": "^16.10.2",
"react-big-calendar": "^0.20.4",
"react-dom": "^16.10.2",
"react-sizes": "^2.0.0",
"turbolinks": "^5.2.0",
"url-polyfill": "^1.1.3"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.6.2",
"webpack-dev-server": "2.11.2"
}
"name": "schedules",
"private": true,
"dependencies": {
"@babel/polyfill": "^7.0.0",
"@prettier/plugin-ruby": "^0.15.0",
"@rails/webpacker": "3.5",
"babel-preset-react": "^6.24.1",
"file-saver": "^2.0.0",
"jquery": "^3.3.1",
"prop-types": "^15.7.2",
"stimulus": "^1.1.1",
"turbolinks": "^5.2.0",
"url-polyfill": "^1.1.3"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.6.2",
"webpack-dev-server": "2.11.2"
}
}
......@@ -86,6 +86,30 @@
webpack "^3.12.0"
webpack-manifest-plugin "^1.3.2"
"@stimulus/core@^1.1.1":