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
12 changes: 12 additions & 0 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,18 @@ def sub_organizations

send_data csv, filename: "#{@event.name}'s sub-organizations.csv", type: "text/csv", disposition: :attachment
end

# Like the CSV, the XLSX export intentionally does not consider filters.
# Unlike the CSV, it includes every visible descendant (not just direct
# sub-organizations), rendered as a collapsible tree via row grouping.
format.xlsx do
send_data(
Event::SubOrganizationsExport.new(@event, descendant_ids: visible_descendant_ids).xlsx,
filename: "#{@event.name}'s sub-organizations.xlsx",
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
disposition: :attachment
)
end
end

end
Expand Down
74 changes: 74 additions & 0 deletions app/models/event/sub_organizations_export.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# frozen_string_literal: true

class Event
class SubOrganizationsExport
# Excel supports at most 7 levels of row grouping. Deeper descendants are
# still exported, they just share the deepest collapsible level.
MAX_OUTLINE_LEVEL = 7

def initialize(event, descendant_ids:)
@event = event
@descendant_ids = descendant_ids
end

def xlsx
io = StringIO.new
workbook = WriteXLSX.new(io)

bold = workbook.add_format(bold: 1)

worksheet = workbook.add_worksheet("Sub-organizations")
# Attach each group's collapse toggle to the parent row above it rather
# than the default summary row below.
worksheet.outline_settings(1, 0, 0, 0)
worksheet.set_column("A:A", 20)
worksheet.set_column("B:C", 40)
worksheet.set_column("D:D", 12)
worksheet.set_column("E:E", 30)

worksheet.write_row(0, 0, ["ID", "Name", "Slug", "Balance", "Tags"], bold)

@current_row = 1
children_of(@event.id).each { |subevent| write_subtree(worksheet, subevent, 0) }

workbook.close
io.string
end

private

def write_subtree(worksheet, event, depth)
tags_for_parent = event.scoped_tags.select { |tag| tag.parent_event_id == event.parent_id }

# write_string keeps user-provided values (e.g. a name starting with "=")
# from being interpreted as formulas.
worksheet.write_string(@current_row, 0, event.public_id)
worksheet.write_string(@current_row, 1, "#{" " * depth}#{event.name}")
worksheet.write_string(@current_row, 2, event.slug)
worksheet.write_number(@current_row, 3, event.balance_v2_cents / 100.0)
worksheet.write_string(@current_row, 4, tags_for_parent.map(&:name).join(", "))

if depth.positive?
# Syntax: set_row(row, height, format, hidden, level, collapsed)
worksheet.set_row(@current_row, nil, nil, 0, depth.clamp(0, MAX_OUTLINE_LEVEL))
end

@current_row += 1

children_of(event.id).each { |child| write_subtree(worksheet, child, depth + 1) }
end

def children_of(parent_id)
children_by_parent_id[parent_id] || []
end

def children_by_parent_id
@children_by_parent_id ||= Event.where(id: @descendant_ids)
.includes(:scoped_tags)
.order(:name)
.group_by(&:parent_id)
end

end

end
17 changes: 14 additions & 3 deletions app/views/events/sub_organizations.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@
<h1 class="heading flex items-center gap-2 flex-col sm:flex-row border-0 mb-0">
<span class="flex-grow">Sub-organizations</span>
<div class="flex items-center gap-2">
<%= pop_icon_to "download",
{ format: :csv },
class: "tooltipped tooltipped--w", "aria-label": "Download all as CSV" %>
<div data-controller="menu" data-menu-placement-value="bottom-end">
<button
type="button"
aria-label="Download"
class="tooltipped tooltipped--w pop menu__toggle menu__toggle--arrowless overflow-visible"
data-menu-target="toggle"
data-action="menu#toggle click@document->menu#close keydown@document->menu#keydown">
<%= inline_icon "download", size: 28 %>
</button>
<div class="menu__content menu__content--2 h5" data-menu-target="content">
<%= link_to "Download as CSV", { format: :csv }, target: :_blank %>
<%= link_to "Download tree as Excel", { format: :xlsx }, target: :_blank %>
</div>
</div>
<%= link_to event_new_sub_organization_path(@event), class: "btn bg-success", data: { behavior: "modal_trigger", modal: "create" }, disabled: !policy(@event).create_sub_organization? do %>
<%= inline_icon "plus" %>
Create a sub-organization
Expand Down
31 changes: 31 additions & 0 deletions spec/controllers/events_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ def sign_in_organizer_of(event)
create_session(organizer, verified: true)
end

# XLSX files are zip archives; cell text lives in the shared strings table.
def xlsx_entry(body, entry)
Zip::File.open_buffer(StringIO.new(body)).read(entry)
end

def xlsx_strings(body)
Nokogiri::XML(xlsx_entry(body, "xl/sharedStrings.xml")).css("si").map(&:text)
end

describe "#index" do
before do
# This is required since creating event configs creates a monthly announcement for the event authored by the system user
Expand Down Expand Up @@ -246,6 +255,14 @@ def sign_in_organizer_of(event)
expect(rows["Transparent Grandchild"]["ID"]).to eq(grandchild.public_id)
expect(rows["Transparent Grandchild"]["Parent ID"]).to eq(transparent_sub.public_id)
end

it "excludes private sub-organizations from the XLSX export", :aggregate_failures do
get(:sub_organizations, params: { event_id: parent.slug }, format: :xlsx)

strings = xlsx_strings(response.body)
expect(strings).to include("Transparent Subsidiary")
expect(strings).not_to include("Private Subsidiary")
end
end

context "with a hidden sub-organization" do
Expand Down Expand Up @@ -291,6 +308,20 @@ def sign_in_organizer_of(event)
expect(response.body).to include("Transparent Sub-organization")
expect(response.body).to include("Private Sub-organization")
end

it "renders every descendant as a collapsible tree in the XLSX export", :aggregate_failures do
nested = create(:event, parent: transparent_sub, is_public: true, name: "Nested Subsidiary")
sign_in_organizer_of(parent)

get(:sub_organizations, params: { event_id: parent.slug }, format: :xlsx)

strings = xlsx_strings(response.body)
expect(strings).to include("Transparent Subsidiary", "Private Subsidiary")
# Nested descendants are indented under their parent...
expect(strings).to include(" #{nested.name}")
# ...and grouped so Excel renders them collapsible.
expect(xlsx_entry(response.body, "xl/worksheets/sheet1.xml")).to include('outlineLevel="1"')
end
end
end

Expand Down
Loading