From 45876784d3ed5c0091e6d152a466641e3c149b4a Mon Sep 17 00:00:00 2001 From: Anish <22670462+anishanne@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:36:08 +0530 Subject: [PATCH 1/2] [Sub-org] Add Excel export with collapsible suborg tree --- app/controllers/events_controller.rb | 12 ++++ app/models/event/sub_organizations_export.rb | 74 ++++++++++++++++++++ app/views/events/sub_organizations.html.erb | 3 + spec/controllers/events_controller_spec.rb | 31 ++++++++ 4 files changed, 120 insertions(+) create mode 100644 app/models/event/sub_organizations_export.rb diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 20d09c607d..79721c71f9 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -944,6 +944,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 9e218da879..d7a61eef51 100644 --- a/app/views/events/sub_organizations.html.erb +++ b/app/views/events/sub_organizations.html.erb @@ -12,6 +12,9 @@ <%= pop_icon_to "download", { format: :csv }, class: "tooltipped tooltipped--w", "aria-label": "Download all as CSV" %> + <%= pop_icon_to "grid", + { format: :xlsx }, + class: "tooltipped tooltipped--w", "aria-label": "Download tree as Excel" %> <%= 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 subsidiary diff --git a/spec/controllers/events_controller_spec.rb b/spec/controllers/events_controller_spec.rb index 3032ed79d4..a8cfeb05fb 100644 --- a/spec/controllers/events_controller_spec.rb +++ b/spec/controllers/events_controller_spec.rb @@ -24,6 +24,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 @@ -233,6 +242,14 @@ def sign_in_organizer_of(event) expect(response.body).to include("Transparent Subsidiary") expect(response.body).not_to include("Private Subsidiary") 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 @@ -278,6 +295,20 @@ def sign_in_organizer_of(event) expect(response.body).to include("Transparent Subsidiary") expect(response.body).to include("Private Subsidiary") 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 From ea4740fefdd268f874efef01a7b4616513de4f8b Mon Sep 17 00:00:00 2001 From: Anish <22670462+anishanne@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:12:14 +0530 Subject: [PATCH 2/2] Combine sub-org export buttons into a download dropdown --- app/views/events/sub_organizations.html.erb | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/views/events/sub_organizations.html.erb b/app/views/events/sub_organizations.html.erb index d7a61eef51..745a405221 100644 --- a/app/views/events/sub_organizations.html.erb +++ b/app/views/events/sub_organizations.html.erb @@ -9,12 +9,20 @@