mirror of
https://github.com/edcommonwealth/Dashboard.git
synced 2026-03-07 21:38:14 -08:00
chore: start adding browse view
This commit is contained in:
parent
a538eb72f2
commit
f71f88a4ac
12 changed files with 296 additions and 87 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AnalyzeController < SqmApplicationController
|
module Dashboard
|
||||||
def index
|
class AnalyzeController < SqmApplicationController
|
||||||
@presenter = Analyze::Presenter.new(params:, school: @school, academic_year: @academic_year)
|
def index
|
||||||
@background ||= BackgroundPresenter.new(num_of_columns: @presenter.graph.columns.count)
|
@presenter = Analyze::Presenter.new(params:, school: @school, academic_year: @academic_year)
|
||||||
|
@background ||= BackgroundPresenter.new(num_of_columns: @presenter.graph.columns.count)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class CategoriesController < SqmApplicationController
|
module Dashboard
|
||||||
helper GaugeHelper
|
class CategoriesController < SqmApplicationController
|
||||||
|
helper GaugeHelper
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@categories = Category.sorted.map { |category| CategoryPresenter.new(category:) }
|
@categories = Category.sorted.map { |category| CategoryPresenter.new(category:) }
|
||||||
|
|
||||||
@category = CategoryPresenter.new(category: Category.find_by_slug(params[:id]))
|
@category = CategoryPresenter.new(category: Category.find_by_slug(params[:id]))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,98 +2,99 @@
|
||||||
|
|
||||||
Point = Struct.new(:x, :y)
|
Point = Struct.new(:x, :y)
|
||||||
Rect = Struct.new(:x, :y, :width, :height)
|
Rect = Struct.new(:x, :y, :width, :height)
|
||||||
|
module Dashboard
|
||||||
|
module GaugeHelper
|
||||||
|
def outer_radius
|
||||||
|
100
|
||||||
|
end
|
||||||
|
|
||||||
module GaugeHelper
|
def inner_radius
|
||||||
def outer_radius
|
50
|
||||||
100
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def inner_radius
|
def stroke_width
|
||||||
50
|
1
|
||||||
end
|
end
|
||||||
|
|
||||||
def stroke_width
|
def effective_radius
|
||||||
1
|
outer_radius + stroke_width
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_radius
|
def diameter
|
||||||
outer_radius + stroke_width
|
2 * effective_radius
|
||||||
end
|
end
|
||||||
|
|
||||||
def diameter
|
def width
|
||||||
2 * effective_radius
|
diameter
|
||||||
end
|
end
|
||||||
|
|
||||||
def width
|
def height
|
||||||
diameter
|
outer_radius + 2 * stroke_width + key_benchmark_indicator_gutter
|
||||||
end
|
end
|
||||||
|
|
||||||
def height
|
def key_benchmark_indicator_gutter
|
||||||
outer_radius + 2 * stroke_width + key_benchmark_indicator_gutter
|
10
|
||||||
end
|
end
|
||||||
|
|
||||||
def key_benchmark_indicator_gutter
|
def viewbox
|
||||||
10
|
x = arc_center.x - effective_radius
|
||||||
end
|
y = arc_center.y - effective_radius - key_benchmark_indicator_gutter
|
||||||
|
Rect.new(x, y, width, height)
|
||||||
|
end
|
||||||
|
|
||||||
def viewbox
|
def arc_center
|
||||||
x = arc_center.x - effective_radius
|
Point.new(0, 0)
|
||||||
y = arc_center.y - effective_radius - key_benchmark_indicator_gutter
|
end
|
||||||
Rect.new(x, y, width, height)
|
|
||||||
end
|
|
||||||
|
|
||||||
def arc_center
|
def arc_radius(radius)
|
||||||
Point.new(0, 0)
|
"#{radius} #{radius}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def arc_radius(radius)
|
def angle_for(percentage:)
|
||||||
"#{radius} #{radius}"
|
-Math::PI * (1 - percentage)
|
||||||
end
|
end
|
||||||
|
|
||||||
def angle_for(percentage:)
|
def arc_end_point_for(radius:, percentage:)
|
||||||
-Math::PI * (1 - percentage)
|
angle = angle_for(percentage:)
|
||||||
end
|
|
||||||
|
|
||||||
def arc_end_point_for(radius:, percentage:)
|
x = arc_center.x + radius * Math.cos(angle)
|
||||||
angle = angle_for(percentage:)
|
y = arc_center.y + radius * Math.sin(angle)
|
||||||
|
Point.new(x, y)
|
||||||
|
end
|
||||||
|
|
||||||
x = arc_center.x + radius * Math.cos(angle)
|
def arc_end_line_destination(radius:, percentage:)
|
||||||
y = arc_center.y + radius * Math.sin(angle)
|
angle = angle_for(percentage:)
|
||||||
Point.new(x, y)
|
x = arc_center.x + radius * Math.cos(angle)
|
||||||
end
|
y = arc_center.y + radius * Math.sin(angle)
|
||||||
|
Point.new(x, y)
|
||||||
|
end
|
||||||
|
|
||||||
def arc_end_line_destination(radius:, percentage:)
|
def arc_start_point
|
||||||
angle = angle_for(percentage:)
|
Point.new(arc_center.x - outer_radius, arc_center.y)
|
||||||
x = arc_center.x + radius * Math.cos(angle)
|
end
|
||||||
y = arc_center.y + radius * Math.sin(angle)
|
|
||||||
Point.new(x, y)
|
|
||||||
end
|
|
||||||
|
|
||||||
def arc_start_point
|
def move_to(point:)
|
||||||
Point.new(arc_center.x - outer_radius, arc_center.y)
|
"M #{coordinates_for(point)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def move_to(point:)
|
def draw_arc(radius:, percentage:, clockwise:)
|
||||||
"M #{coordinates_for(point)}"
|
sweep_flag = clockwise ? 1 : 0
|
||||||
end
|
"A #{arc_radius(radius)} 0 0 #{sweep_flag} #{coordinates_for(arc_end_point_for(radius:,
|
||||||
|
percentage:))}"
|
||||||
|
end
|
||||||
|
|
||||||
def draw_arc(radius:, percentage:, clockwise:)
|
def draw_line_to(point:)
|
||||||
sweep_flag = clockwise ? 1 : 0
|
"L #{coordinates_for(point)}"
|
||||||
"A #{arc_radius(radius)} 0 0 #{sweep_flag} #{coordinates_for(arc_end_point_for(radius:,
|
end
|
||||||
percentage:))}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def draw_line_to(point:)
|
def benchmark_line_point(radius, angle)
|
||||||
"L #{coordinates_for(point)}"
|
x = (radius * Math.cos(angle)).to_s
|
||||||
end
|
y = (radius * Math.sin(angle) + arc_center.y).to_s
|
||||||
|
Point.new(x, y)
|
||||||
|
end
|
||||||
|
|
||||||
def benchmark_line_point(radius, angle)
|
def coordinates_for(point)
|
||||||
x = (radius * Math.cos(angle)).to_s
|
"#{point.x} #{point.y}"
|
||||||
y = (radius * Math.sin(angle) + arc_center.y).to_s
|
end
|
||||||
Point.new(x, y)
|
|
||||||
end
|
|
||||||
|
|
||||||
def coordinates_for(point)
|
|
||||||
"#{point.x} #{point.y}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
module Dashboard
|
module Dashboard
|
||||||
class Race < ApplicationRecord
|
class Race < ApplicationRecord
|
||||||
include FriendlyId
|
include FriendlyId
|
||||||
has_many :dashboard_student_races
|
has_and_belongs_to_many :students, join_table: :dashboard_student_races, class_name: "Student",
|
||||||
has_many :dashboard_students, through: :student_races
|
foreign_key: :dashboard_student_id, association_foreign_key: :dashboard_student_id
|
||||||
|
|
||||||
friendly_id :designation, use: [:slugged]
|
friendly_id :designation, use: [:slugged]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
module Dashboard
|
module Dashboard
|
||||||
class Student < ApplicationRecord
|
class Student < ApplicationRecord
|
||||||
# has_many :dashboard_survey_item_responses
|
# has_many :dashboard_survey_item_responses
|
||||||
has_many :dashboard_student_races
|
has_and_belongs_to_many :races, join_table: :dashboard_student_races, class_name: "Race",
|
||||||
has_and_belongs_to_many :races, join_table: :student_races
|
foreign_key: :dashboard_race_id, association_foreign_key: :dashboard_race_id
|
||||||
|
|
||||||
encrypts :lasid, deterministic: true
|
encrypts :lasid, deterministic: true
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ module Dashboard
|
||||||
def self.grouped_responses(school:, academic_year:)
|
def self.grouped_responses(school:, academic_year:)
|
||||||
@grouped_responses ||= Hash.new do |memo, (school, academic_year)|
|
@grouped_responses ||= Hash.new do |memo, (school, academic_year)|
|
||||||
memo[[school, academic_year]] =
|
memo[[school, academic_year]] =
|
||||||
SurveyItemResponse.where(school:, academic_year:).group(:survey_item_id).average(:likert_score)
|
SurveyItemResponse.where(school:, academic_year:).group(:dashboard_survey_item_id).average(:likert_score)
|
||||||
end
|
end
|
||||||
@grouped_responses[[school, academic_year]]
|
@grouped_responses[[school, academic_year]]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
42
app/views/dashboard/categories/_data_item_section.html.erb
Normal file
42
app/views/dashboard/categories/_data_item_section.html.erb
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h3 class="accordion-header measure-accordion-header" id="<%= data_item_section.id %>-header">
|
||||||
|
<button
|
||||||
|
class="accordion-button measure-accordion-button collapsed"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#<%= data_item_section.id %>"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="<%= data_item_section.id %>"
|
||||||
|
>
|
||||||
|
<%= data_item_section.title %>
|
||||||
|
<% unless data_item_section.sufficient_data? %>
|
||||||
|
<i class="fa-solid fa-circle-exclamation" data-exclamation-point="<%= data_item_section.id %>"></i>
|
||||||
|
<% end %>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="<%= data_item_section.id %>"
|
||||||
|
class="accordion-collapse collapse"
|
||||||
|
aria-labelledby="<%= data_item_section.id %>-header"
|
||||||
|
data-bs-parent="#<%= data_item_section.data_item_accordion_id %>"
|
||||||
|
>
|
||||||
|
<div class="accordion-body measure-accordion-body font-cabin font-size-14 weight-400">
|
||||||
|
<% unless data_item_section.sufficient_data? %>
|
||||||
|
<div class="alert alert-secondary" role="alert" data-insufficient-data-message="<%= data_item_section.id + '-' + data_item_section.reason_for_insufficiency %>">
|
||||||
|
Data not included due to <%= data_item_section.reason_for_insufficiency %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<ul>
|
||||||
|
<% data_item_section.descriptions_and_availability.each do |data| %>
|
||||||
|
<li><%= data.description %>
|
||||||
|
<% unless data.available? %>
|
||||||
|
<i class="fa-solid fa-circle-exclamation" data-missing-data="<%= data.id %>"
|
||||||
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
|
data-bs-content="Data not included due to limited availability"></i>
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
59
app/views/dashboard/categories/_gauge_graph.html.erb
Normal file
59
app/views/dashboard/categories/_gauge_graph.html.erb
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<div class="d-flex flex-column align-items-center position-relative">
|
||||||
|
<% if ENV["SCORES"].present? && ENV["SCORES"].upcase == "SHOW" %>
|
||||||
|
<p>Score is : <%= gauge.score %> </p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox="<%= viewbox.x %> <%= viewbox.y %> <%= viewbox.width %> <%= viewbox.height %>"
|
||||||
|
class="<%= gauge_class %>"
|
||||||
|
>
|
||||||
|
<% if gauge.score_percentage.present? %>
|
||||||
|
<path
|
||||||
|
class="gauge-fill <%= gauge.color_class %>"
|
||||||
|
d="<%= move_to(point: arc_start_point) %>
|
||||||
|
<%= draw_arc(radius: outer_radius, percentage: gauge.score_percentage, clockwise: true) %>
|
||||||
|
<%= draw_line_to(point: arc_end_line_destination(radius: inner_radius, percentage: gauge.score_percentage)) %>
|
||||||
|
<%= draw_arc(radius: inner_radius, percentage: 0, clockwise: false) %>
|
||||||
|
<%= draw_line_to(point: arc_end_line_destination(radius: outer_radius, percentage: 0)) %>"
|
||||||
|
fill="none"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
|
||||||
|
<path
|
||||||
|
class="gauge-outline stroke-gray-2"
|
||||||
|
d="<%= move_to(point: arc_start_point) %>
|
||||||
|
<%= draw_arc(radius: outer_radius, percentage: 1, clockwise: true) %>
|
||||||
|
<%= draw_line_to(point: arc_end_line_destination(radius: inner_radius, percentage: 1)) %>
|
||||||
|
<%= draw_arc(radius: inner_radius, percentage: 0, clockwise: false) %>
|
||||||
|
<%= draw_line_to(point: arc_end_line_destination(radius: outer_radius, percentage: 0)) %>"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="<%= stroke_width %>"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<% benchmark_boundaries = [:watch_low, :growth_low, :ideal_low]%>
|
||||||
|
<% benchmark_boundaries.each do |zone| %>
|
||||||
|
<line
|
||||||
|
class="zone-benchmark stroke-gray-2"
|
||||||
|
x1="<%= benchmark_line_point(outer_radius, angle_for(percentage: gauge.boundary_percentage_for(zone))).x %>"
|
||||||
|
y1="<%= benchmark_line_point(outer_radius, angle_for(percentage: gauge.boundary_percentage_for(zone))).y %>"
|
||||||
|
x2="<%= benchmark_line_point(inner_radius, angle_for(percentage: gauge.boundary_percentage_for(zone))).x %>"
|
||||||
|
y2="<%= benchmark_line_point(inner_radius, angle_for(percentage: gauge.boundary_percentage_for(zone))).y %>"
|
||||||
|
stroke-width="<%= stroke_width %>"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if gauge.key_benchmark_percentage.present? %>
|
||||||
|
<line
|
||||||
|
class="zone-benchmark stroke-black"
|
||||||
|
x1="<%= benchmark_line_point(outer_radius + 5, angle_for(percentage: gauge.key_benchmark_percentage)).x %>"
|
||||||
|
y1="<%= benchmark_line_point(outer_radius + 5, angle_for(percentage: gauge.key_benchmark_percentage)).y %>"
|
||||||
|
x2="<%= benchmark_line_point(inner_radius - 5 , angle_for(percentage: gauge.key_benchmark_percentage)).x %>"
|
||||||
|
y2="<%= benchmark_line_point(inner_radius - 5, angle_for(percentage: gauge.key_benchmark_percentage)).y %>"
|
||||||
|
stroke-width="<%= stroke_width + 2 %>"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
</svg>
|
||||||
|
<span class="gauge-title <%= font_class %> fill-black"><%= gauge.title %></span>
|
||||||
|
</div>
|
||||||
12
app/views/dashboard/categories/_measures_section.html.erb
Normal file
12
app/views/dashboard/categories/_measures_section.html.erb
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<div id="<%= measure_presenter.id %>" class="measure-section mx-4">
|
||||||
|
<p class="construct-id">Measure <%= measure_presenter.id %></p>
|
||||||
|
<h3 class="measure-description sub-header-4 mb-5 "><%= measure_presenter.name %></h3>
|
||||||
|
<div>
|
||||||
|
<%= render partial: "gauge_graph", locals: { gauge: measure_presenter.gauge_presenter, gauge_class: 'gauge-graph-sm', font_class: 'weight-700' } %>
|
||||||
|
</div>
|
||||||
|
<p class="measure-description body-small mt-5 mb-4"><%= measure_presenter.description %></p>
|
||||||
|
|
||||||
|
<div class="measure-accordion accordion" id="<%= measure_presenter.data_item_accordion_id %>">
|
||||||
|
<%= render partial: "data_item_section", collection: measure_presenter.data_item_presenters %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
59
app/views/dashboard/categories/_subcategory_section.html.erb
Normal file
59
app/views/dashboard/categories/_subcategory_section.html.erb
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<section class="subcategory-section">
|
||||||
|
<div id="<%= subcategory.id %>" class="p-7">
|
||||||
|
<p class="construct-id">Subcategory <%= subcategory.id %></p>
|
||||||
|
<h2 class="sub-header-2 font-bitter mb-7"><%= subcategory.name %></h2>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-end">
|
||||||
|
<div>
|
||||||
|
<%= render partial: "gauge_graph", locals: { gauge: subcategory.gauge_presenter, gauge_class: 'gauge-graph-lg', font_class: 'sub-header-3' } %>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column mx-7">
|
||||||
|
<p class="body-large "><%= subcategory.description %></p>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-start">
|
||||||
|
<div
|
||||||
|
class="body-large text-center response-rate"
|
||||||
|
data-bs-toggle="popover"
|
||||||
|
data-bs-trigger="hover focus"
|
||||||
|
data-bs-content="The number of publicly available school data sources, often collected from the MA Department of Elementary and Secondary Education."
|
||||||
|
data-bs-placement="bottom"
|
||||||
|
>
|
||||||
|
<p class="response-rate-percentage"><%= subcategory.admin_collection_rate.first %> / <%= subcategory.admin_collection_rate.last %></p>
|
||||||
|
<p>school admin data sources</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="body-large mx-3 text-center response-rate"
|
||||||
|
data-bs-toggle="popover"
|
||||||
|
data-bs-trigger="hover focus"
|
||||||
|
data-bs-content="The student survey response rate for this sub-category. This number differs from the overall response rate because each individual student receives 44 of 67 total questions, in order to avoid survey fatigue."
|
||||||
|
data-bs-placement="bottom"
|
||||||
|
>
|
||||||
|
<p class="response-rate-percentage"><%= subcategory.student_response_rate %></p>
|
||||||
|
<p>of students responded</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="body-large text-center response-rate"
|
||||||
|
data-bs-toggle="popover"
|
||||||
|
data-bs-trigger="hover focus"
|
||||||
|
data-bs-content="The teacher survey response rate for this sub-category. This number differs from the overall response rate because the survey includes skip logic to limit the number of questions for each individual survey respondent."
|
||||||
|
data-bs-placement="bottom"
|
||||||
|
>
|
||||||
|
<p class="response-rate-percentage"><%= subcategory.teacher_response_rate %></p>
|
||||||
|
<p>of teachers responded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="arrow-container mt-7">
|
||||||
|
<div class="arrow-shadow"></div>
|
||||||
|
<div class="arrow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="measure-card d-flex p-7">
|
||||||
|
<% subcategory.measure_presenters.each do |measure_presenter| %>
|
||||||
|
<%= render partial: "measures_section", locals: { measure_presenter: measure_presenter } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
25
app/views/dashboard/categories/show.html.erb
Normal file
25
app/views/dashboard/categories/show.html.erb
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<% content_for :navigation do %>
|
||||||
|
<nav class="nav nav-tabs align-self-end">
|
||||||
|
<% @categories.each do |category| %>
|
||||||
|
<div class="nav-item">
|
||||||
|
<%= link_to [@district, @school, category, { year: @academic_year.range }], class: ["nav-link", current_page?([@district, @school, category, { year: @academic_year.range }]) ? "active" : ""] do %>
|
||||||
|
<i class="<%= category.icon_class %> <%= category.icon_color_class if current_page?([@district, @school, category, { year: @academic_year.range }]) %> me-2" ></i>
|
||||||
|
<%= category.name %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
<select id="select-academic-year" class="form-select" name="academic-year">
|
||||||
|
<% @academic_years.each do |year| %>
|
||||||
|
<option value="<%= url_for [@district, @school, @category , {year: year.range} ]%>" <%= @academic_year == year ? "selected" : nil %>><%= year.formatted_range %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<% end %>
|
||||||
|
<% cache [@category, @school, @academic_year] do %>
|
||||||
|
<p class="construct-id">Category <%= @category.id %></p>
|
||||||
|
<h1 class="sub-header font-bitter color-red"><%= @category.name %></h1>
|
||||||
|
<p class="col-8 body-large"><%= @category.description %></p>
|
||||||
|
<% @category.subcategories(academic_year: @academic_year, school: @school).each do |subcategory| %>
|
||||||
|
<%= render partial: "subcategory_section", locals: {subcategory: subcategory} %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
7
config/initializers/dashboard/string_monkey_patches.rb
Normal file
7
config/initializers/dashboard/string_monkey_patches.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module StringMonkeyPatches
|
||||||
|
def valid_likert_score?
|
||||||
|
to_i.between? 1, 5
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
String.include StringMonkeyPatches
|
||||||
Loading…
Add table
Add a link
Reference in a new issue