Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ gem "activerecord-import", require: false
gem "httparty", "~> 0.21.0"

gem "country_select", "~> 8.0"
gem "ajax-datatables-rails"

group :development do
gem "annotate"
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
ajax-datatables-rails (1.5.0)
rails (>= 6.0)
zeitwerk
annotate (3.1.1)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
Expand Down Expand Up @@ -567,6 +570,7 @@ PLATFORMS

DEPENDENCIES
activerecord-import
ajax-datatables-rails
annotate
aws-sdk-s3
axe-core-cucumber
Expand Down
5 changes: 4 additions & 1 deletion app/controllers/schools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ class SchoolsController < ApplicationController
before_action :require_admin

def index
@schools = School.all.order(:name)
respond_to do |format|
format.html
format.json { render json: SchoolDatatable.new(params) }
end
end

def show
Expand Down
63 changes: 63 additions & 0 deletions app/datatables/school_datatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

class SchoolDatatable < AjaxDatatablesRails::ActiveRecord
include Rails.application.routes.url_helpers

def view_columns
@view_columns ||= {
name: { source: "School.name", cond: :like },
location: { source: "School.city", cond: :like },
country: { source: "School.country", cond: :like },
website: { source: "School.website", cond: :like },
teachers_count: { source: "School.teachers_count", searchable: false },
grade_level: { source: "School.grade_level", searchable: false },
actions: { source: "School.id", searchable: false, orderable: false }
}
end

def data
records.map do |record|
{
name: name_link(record),
location: record.location,
country: record.country,
website: website_link(record),
teachers_count: record.teachers_count,
grade_level: record.display_grade_level,
actions: action_links(record),
DT_RowId: record.id
}
end
end

def get_raw_records
School.all
end

private
SEARCHABLE_COLUMNS = %w[name city state country website].freeze

def filter_records(records)
search_value = params.dig(:search, :value).presence
return records unless search_value

conditions = SEARCHABLE_COLUMNS.map { |col| "schools.#{col} ILIKE :q" }.join(" OR ")
records.where(conditions, q: "%#{search_value}%")
end

def name_link(record)
"<a href=\"#{school_path(record)}\">#{ERB::Util.html_escape(record.name)}</a>".html_safe
end

def website_link(record)
url = record.website
display = url.to_s.truncate(30)
"<a href=\"#{ERB::Util.html_escape(url)}\" target=\"_blank\">#{ERB::Util.html_escape(display)}</a>".html_safe
end

def action_links(record)
edit = "<a class=\"btn btn-info\" href=\"#{edit_school_path(record)}\">Edit</a>"
delete = "<a class=\"btn btn-outline-danger\" data-confirm=\"Are you sure?\" rel=\"nofollow\" data-method=\"delete\" href=\"#{school_path(record)}\">❌</a>"
"<span class=\"btn-group\">#{edit} #{delete}</span>".html_safe
end
end
1 change: 1 addition & 0 deletions app/javascript/packs/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import "datatables.net-buttons-bs4";
import 'datatables.net-buttons/js/buttons.html5.js';

import './datatables.js';
import './schools_index.js';
import '../styles/application.scss';
import './schools.js';

Expand Down
34 changes: 34 additions & 0 deletions app/javascript/packs/schools_index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function initSchoolsTable() {
var $table = $('#schools-table');
if (!$table.length || $.fn.DataTable.isDataTable($table)) return;

$table.DataTable({
serverSide: true,
processing: true,
ajax: $table.data('source'),
pageLength: 100,
lengthMenu: [[25, 50, 100, 250], [25, 50, 100, 250]],
columns: [
{ data: 'name' },
{ data: 'location' },
{ data: 'country' },
{ data: 'website' },
{ data: 'teachers_count', searchable: false },
{ data: 'grade_level', searchable: false },
{ data: 'actions', orderable: false, searchable: false }
],
dom:
"<'row form-row'<'col-6 form-inline'i><'col-6 form-inline'lf>>" +
"<'row'<'col-12'tr>>" +
"<'row'<'col-sm-12 col-md-5'B><'col-sm-12 col-md-4'p>>",
buttons: ['copy', 'csv'],
language: {
search: '_INPUT_',
searchPlaceholder: 'Search'
},
autoWidth: false
});
}

$(document).ready(initSchoolsTable);
$(document).on('turbolinks:load', initSchoolsTable);
20 changes: 1 addition & 19 deletions app/views/schools/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<%= provide(:title, "BJC Schools") %>
<%= provide(:header_button, "New School") %>

<table class="table table-striped js-dataTable">
<table id="schools-table" class="table table-striped" data-source="<%= schools_path(format: :json) %>">
<thead class="thead-dark">
<tr>
<th scope="col">Name</th>
Expand All @@ -14,23 +14,5 @@
</tr>
</thead>
<tbody>
<% @schools.each_with_index do |school, index| %>
<tr>
<td><%= link_to(school.name, school_path(school)) %></td>
<td><%= school.location %></td>
<td><%= school.country %></td>
<td>
<%= link_to(truncate(school.website, length: 30), school.website, target: "_blank") %>
</td>
<td><%= school.teachers_count %></td>
<td><%= school.display_grade_level %></td>
<td>
<span class="btn-group">
<%= link_to("Edit", edit_school_path(school), class: "btn btn-info") %>
<%= link_to("❌", school_path(school), method: "delete", class: "btn btn-outline-danger", data: {confirm: "Are you sure?"}) %>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
30 changes: 30 additions & 0 deletions features/schools_datatable.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Feature: Schools page uses server-side DataTables
As an admin
So that the schools page loads quickly with many records
The table fetches data from the server via AJAX

Background:
Given the following schools exist:
| name | country | city | state | website | grade_level | school_type |
| UC Berkeley | US | Berkeley | CA | https://www.berkeley.edu | university | public |
| Stanford | US | Palo Alto | CA | https://www.stanford.edu | university | private |
| MIT | US | Cambridge | MA | https://www.mit.edu | university | private |
And the following teachers exist:
| first_name | last_name | admin | primary_email |
| Admin | User | true | testadminuser@berkeley.edu |
Given I am on the BJC home page
And I have an admin email
And I follow "Log In"
Then I can log in with Google

Scenario: Schools page loads and displays data via server-side DataTables
When I go to the schools page
Then I should see "UC Berkeley"
And I should see "Stanford"
And I should see "MIT"

Scenario: Searching filters school results
When I go to the schools page
And I search the schools table for "Berkeley"
Then I should see "UC Berkeley"
And I should not see "MIT"
5 changes: 5 additions & 0 deletions features/step_definitions/page_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
check checkbox
end


When(/^I search the schools table for "([^"]*)"$/) do |query|
find("#schools-table_filter input").set(query)
end

When(/^(?:|I )fill in the page HTML content with "([^"]*)"$/) do |value|
page.execute_script('$(tinyMCE.editors[0].setContent("' + value + '"))')
end
Expand Down
133 changes: 129 additions & 4 deletions spec/controllers/schools_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,134 @@
end
end

RSpec.describe "Schools DataTables JSON API", type: :request do
fixtures :all

let(:admin_teacher) { teachers(:admin) }

before { log_in(admin_teacher) }

def datatable_params(overrides = {})
{
draw: "1", start: "0", length: "100",
search: { value: "" },
columns: {
"0" => { data: "name", searchable: "true", orderable: "true", search: { value: "" } },
"1" => { data: "location", searchable: "true", orderable: "true", search: { value: "" } },
"2" => { data: "country", searchable: "true", orderable: "true", search: { value: "" } },
"3" => { data: "website", searchable: "true", orderable: "true", search: { value: "" } },
"4" => { data: "teachers_count", searchable: "false", orderable: "true", search: { value: "" } },
"5" => { data: "grade_level", searchable: "false", orderable: "true", search: { value: "" } },
"6" => { data: "actions", searchable: "false", orderable: "false", search: { value: "" } }
},
order: { "0" => { column: "0", dir: "asc" } }
}.deep_merge(overrides)
end

describe "GET /schools.json" do
it "returns JSON with DataTables structure" do
get schools_path(format: :json), params: datatable_params
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json).to have_key("draw")
expect(json).to have_key("recordsTotal")
expect(json).to have_key("recordsFiltered")
expect(json).to have_key("data")
end

it "returns correct recordsTotal and recordsFiltered counts" do
total = School.count
get schools_path(format: :json), params: datatable_params
json = JSON.parse(response.body)
expect(json["recordsTotal"]).to eq(total)
expect(json["recordsFiltered"]).to eq(total)
expect(json["data"].length).to eq(total)
end

it "returns expected attributes for each school record" do
school = School.create!(
name: "Test Academy", city: "Springfield", state: "IL",
country: "US", website: "https://test.edu",
grade_level: :high_school, school_type: :public
)
get schools_path(format: :json), params: datatable_params
json = JSON.parse(response.body)
entry = json["data"].find { |d| d["DT_RowId"].to_i == school.id }
expect(entry).to be_present
expect(entry["name"]).to include("Test Academy")
expect(entry["name"]).to include(school_path(school))
expect(entry["location"]).to include("Springfield")
expect(entry["country"]).to eq("US")
expect(entry["website"]).to include("https://test.edu")
expect(entry["grade_level"]).to include("High School")
expect(entry["actions"]).to include("Edit")
expect(entry["actions"]).to include(edit_school_path(school))
end

it "filters by name" do
School.create!(
name: "Unique Zebra School", city: "Denver", state: "CO",
country: "US", website: "https://zebra.edu",
grade_level: :high_school, school_type: :public
)
get schools_path(format: :json), params: datatable_params(search: { value: "Unique Zebra" })
json = JSON.parse(response.body)
expect(json["recordsFiltered"]).to eq(1)
expect(json["data"].first["name"]).to include("Unique Zebra School")
end

it "filters by state" do
School.create!(
name: "Xylophone Academy", city: "Juneau", state: "AK",
country: "US", website: "https://xylophone.edu",
grade_level: :high_school, school_type: :public
)
get schools_path(format: :json), params: datatable_params(search: { value: "AK" })
json = JSON.parse(response.body)
names = json["data"].map { |d| d["name"] }
expect(names.any? { |n| n.include?("Xylophone Academy") }).to be true
end

it "filters by city" do
School.create!(
name: "Quokka School", city: "Wollongong", state: "NSW",
country: "AU", website: "https://quokka.edu",
grade_level: :high_school, school_type: :public
)
get schools_path(format: :json), params: datatable_params(search: { value: "Wollongong" })
json = JSON.parse(response.body)
expect(json["data"].any? { |d| d["name"].include?("Quokka School") }).to be true
end

it "filters by website" do
School.create!(
name: "Narwhal Institute", city: "Oslo", state: "Oslo",
country: "NO", website: "https://narwhal-unique.edu",
grade_level: :university, school_type: :public
)
get schools_path(format: :json), params: datatable_params(search: { value: "narwhal-unique" })
json = JSON.parse(response.body)
expect(json["data"].any? { |d| d["name"].include?("Narwhal Institute") }).to be true
end

it "paginates results with start and length" do
total = School.count
get schools_path(format: :json), params: datatable_params(start: "0", length: "2")
json = JSON.parse(response.body)
expect(json["data"].length).to eq(2)
expect(json["recordsTotal"]).to eq(total)
expect(json["recordsFiltered"]).to eq(total)
end
end

describe "GET /schools (HTML)" do
it "renders a table with id schools-table for DataTables init" do
get schools_path
expect(response.body).to include('id="schools-table"')
end
end
end

RSpec.describe SchoolsController, type: :controller do
let(:school) { double("School", id: 1, name: "Test School") }

Expand All @@ -263,11 +391,8 @@
end

describe "GET #index" do
it "assigns all schools ordered by name to @schools" do
schools = [double("School", name: "School A"), double("School", name: "School B"), double("School", name: "School C")]
allow(School).to receive_message_chain(:all, :order).and_return(schools)
it "renders the index template" do
get :index
expect(assigns(:schools)).to eq(schools)
expect(response).to render_template("index")
end
end
Expand Down
Loading