Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
20 changes: 20 additions & 0 deletions app/assets/stylesheets/mo/_elements.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ body {
word-wrap: break-word;
}

// Give focus outlines breathing room so they don't clip text/icons.
a:focus,
button:focus,
.btn:focus,
[role="button"]:focus {
outline-offset: 2px;
}

// Inside matrix boxes, overflow:hidden clips outlines. Use an
// inset box-shadow instead so the focus indicator is visible.
.rss-box-details {
a:focus,
button:focus,
.btn:focus,
[role="button"]:focus {
outline: none;
box-shadow: inset 0 0 0 2px rgba($link-color, 0.4);
}
}

blockquote {
font-size: 100%;
}
Expand Down
9 changes: 9 additions & 0 deletions app/assets/stylesheets/mo/_icons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@
// This is for the stateful icon_link_to helper
.icon-link,
.panel-collapse-trigger {
text-decoration: none;

.active-icon, .active-label {
display: none;
}
Expand All @@ -139,6 +141,13 @@
}
}

// Bootstrap collapse sets display:block, which breaks
// table-row-group semantics on <tbody>. Override so
// collapsed tbody rows render correctly when expanded.
tbody.collapse.in {
display: table-row-group;
}

// Fix for weird inherited indent from .name-section p
.glyphicon {
text-indent: 0;
Expand Down
27 changes: 21 additions & 6 deletions app/classes/checklist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,30 @@ def initialize(user)

# Build list of species observed by one Project.
class ForProject < Checklist
def initialize(project, location = nil)
def initialize(project, location = nil,
include_sub_locations: false)
@project = project
@location = location
base = project.visible_observations
@observations = if location.present?
base.within_locations([location])
else
base
end
@observations =
if location.present? && include_sub_locations
sub_location_observations(base, location)
elsif location.present?
base.within_locations([location])
else
base
end
Comment thread
mo-nathan marked this conversation as resolved.
end

def sub_location_observations(base, location)
escaped = ActiveRecord::Base.sanitize_sql_like(
location.name
)
tbl = Location.arel_table
base.joins(:location).where(
tbl[:name].matches("%, #{escaped}").
or(tbl[:name].eq(location.name))
Comment thread
mo-nathan marked this conversation as resolved.
)
end

delegate :target_name_ids, to: :@project
Expand Down
169 changes: 140 additions & 29 deletions app/components/projects/locations_table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,24 @@

module Components
module Projects
# Renders the project locations table with aliases and target
# location remove buttons.
# Renders the project locations table with target location
# grouping, collapsible sub-locations, and aliases.
class LocationsTable < Components::Base
def initialize(project:, locations:, user: nil)
def initialize(project:, grouped_data:,
ungrouped_locations:, obs_counts:,
user: nil)
super()
@project = project
@locations = locations
@grouped_data = grouped_data
@ungrouped_locations = ungrouped_locations
@obs_counts = obs_counts
@user = user
end

def view_template
div(id: "locations_table") do
table(class: "table table-striped " \
"table-project-members mt-3") do
thead { render_header }
tbody do
@locations.each { |loc| render_row(loc) }
end
end
render_target_groups if @grouped_data.any?
render_ungrouped if @ungrouped_locations.any?
end
end

Expand All @@ -30,9 +29,139 @@ def admin?
@project.is_admin?(@user)
end

# --- Target location groups (collapsible) ---

def render_target_groups
table(class: "table table-striped " \
"table-project-members mt-3") do
thead { render_header }
@grouped_data.each do |group|
render_target_group(group)
end
end
end

def render_target_group(group)
target = group[:target]
subs = group[:sub_locations]
collapse_id = "target_subs_#{target.id}"
count = target_obs_count(target, subs)

render_target_row(target, collapse_id, count, subs)
render_sub_location_rows(subs, collapse_id)
end

def render_target_row(target, collapse_id, count, subs)
tbody do
tr do
render_target_name_cell(target, collapse_id, subs)
td(class: "align-middle") { plain(count.to_s) }
render_aliases_cell(target)
render_target_column(target) if admin?
end
end
end

def render_target_name_cell(target, collapse_id, subs)
td(class: "align-middle") do
render_chevron(collapse_id) if subs.any?
plain(" ") if subs.any?
link_to(
target.display_name,
checklist_path(project_id: @project.id,
location_id: target.id,
sub_locations: 1)
)
end
end

def render_sub_location_rows(subs, collapse_id)
return if subs.empty?

tbody(id: collapse_id, class: "collapse") do
subs.each { |loc| render_sub_row(loc) }
end
end
Comment thread
mo-nathan marked this conversation as resolved.

def render_sub_row(loc)
render_location_row(loc, indent: true)
end

def render_chevron(collapse_id)
link_to(
"javascript:void(0)",
role: :button,
class: "panel-collapse-trigger collapsed",
data: { toggle: "collapse",
target: "##{collapse_id}" },
aria: { expanded: false,
controls: collapse_id }
Comment thread
mo-nathan marked this conversation as resolved.
) do
link_icon(:chevron_down, title: :OPEN.l,
class: "active-icon")
link_icon(:chevron_up, title: :CLOSE.l)
end
end

def target_obs_count(target, subs)
count = @obs_counts[target.id] || 0
subs.each { |loc| count += @obs_counts[loc.id] || 0 }
count
end

# --- Ungrouped locations (flat table) ---

def render_ungrouped
table(class: "table table-striped " \
"table-project-members mt-3") do
thead { render_header }
tbody do
@ungrouped_locations.each do |loc|
render_ungrouped_row(loc)
end
end
end
end

def render_ungrouped_row(loc)
render_location_row(loc)
end

# --- Shared ---

def render_location_row(loc, indent: false)
count = @obs_counts[loc.id] || 0
tr do
render_location_name_cell(loc, indent: indent)
td(class: "align-middle") { plain(count.to_s) }
render_aliases_cell(loc)
td { nil } if admin?
end
end

def render_location_name_cell(loc, indent: false)
style = indent ? "padding-left: 2em" : nil
td(class: "align-middle", style: style) do
link_to(
loc.display_name,
checklist_path(project_id: @project.id,
location_id: loc.id)
)
end
end

def render_aliases_cell(loc)
td(class: "align-middle") do
render(Components::ProjectAliases.new(
project: @project, target: loc
))
end
end

def render_header
tr do
th { :LOCATION.t }
th { :OBSERVATIONS.t }
th { :PROJECT_ALIASES.t }
if admin?
th(class: "text-center") do
Expand All @@ -42,24 +171,6 @@ def render_header
end
end

def render_row(loc)
tr do
td(class: "align-middle") do
link_to(
loc.display_name,
checklist_path(project_id: @project.id,
location_id: loc.id)
)
end
td(class: "align-middle") do
render(Components::ProjectAliases.new(
project: @project, target: loc
))
end
render_target_column(loc) if admin?
end
end

def render_target_column(loc)
td(class: "align-middle text-center") do
render_remove_button(loc) if target?(loc)
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/checklists_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ def project_checklist(proj_id, location_id)
return unless (@project = find_or_goto_index(Project, proj_id))

@location = Location.safe_find(location_id)
sub = params[:sub_locations] == "1"

Checklist::ForProject.new(@project, @location)
Checklist::ForProject.new(@project, @location,
include_sub_locations: sub)
end

def species_list_checklist(list_id)
Expand Down
71 changes: 71 additions & 0 deletions app/controllers/concerns/projects/location_grouping.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

module Projects
# Shared logic for grouping project locations under target
# locations by name suffix.
module LocationGrouping
private

def build_grouped_locations(project)
obs_locs = project.locations.distinct.to_a
targets = sorted_targets(project)
sorted_obs = obs_locs.sort_by(&:scientific_name)
return [[], sorted_obs] if targets.empty?

groups = build_groups(obs_locs, targets)
grouped_ids = collect_grouped_ids(groups, targets)
ungrouped = sorted_obs.reject do |l|
grouped_ids.include?(l.id)
end
[groups, ungrouped]
end

def sorted_targets(project)
project.target_locations.
order(:scientific_name).to_a
end

def build_groups(obs_locs, targets)
assignments = assign_to_targets(obs_locs, targets)
targets.map do |target|
subs = (assignments[target.id] || []).
sort_by(&:scientific_name)
{ target: target, sub_locations: subs }
end
end

# Assign each observed location to its most specific
# (longest name) matching target to avoid duplicates.
def assign_to_targets(obs_locs, targets)
assignments = {}
obs_locs.each do |loc|
best = most_specific_target(loc, targets)
next unless best

(assignments[best.id] ||= []) << loc
end
assignments
end

def most_specific_target(loc, targets)
matches = targets.select do |t|
loc.id != t.id && loc.name.end_with?(", #{t.name}")
end
matches.max_by { |t| t.name.length }
end

def collect_grouped_ids(groups, target_locs)
ids = Set.new(target_locs.map(&:id))
groups.each do |g|
g[:sub_locations].each { |loc| ids.add(loc.id) }
end
ids
end

def observation_counts(project)
project.visible_observations.
where.not(location_id: nil).
group(:location_id).count
end
end
end
Loading
Loading