diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 91c6d5f5d3..48f0eefc52 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -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 diff --git a/app/models/event/sub_organizations_export.rb b/app/models/event/sub_organizations_export.rb new file mode 100644 index 0000000000..5ac14b5b69 --- /dev/null +++ b/app/models/event/sub_organizations_export.rb @@ -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 diff --git a/app/views/events/sub_organizations.html.erb b/app/views/events/sub_organizations.html.erb index 06fd7a3262..6256a3cf92 100644 --- a/app/views/events/sub_organizations.html.erb +++ b/app/views/events/sub_organizations.html.erb @@ -9,9 +9,20 @@

Sub-organizations
- <%= pop_icon_to "download", - { format: :csv }, - class: "tooltipped tooltipped--w", "aria-label": "Download all as CSV" %> +
+ + +
<%= 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 diff --git a/spec/controllers/events_controller_spec.rb b/spec/controllers/events_controller_spec.rb index f139d5947d..c2a9a52363 100644 --- a/spec/controllers/events_controller_spec.rb +++ b/spec/controllers/events_controller_spec.rb @@ -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 @@ -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 @@ -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