From 4940981bdcaca5e519c54609a3f0c88a68b691ac Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 18:14:15 -0400
Subject: [PATCH 1/9] Nest organizations under a parent org
Mirrors FileMaker's Organization ParentID so the FM organizations import
can group the split Adult/Children program records under one org roof.
Structural only (no roll-up), arbitrary-depth tree with cycle protection.
Refs rubyforgood/awbw#508
Co-Authored-By: Claude Opus 4.8 (1M context)
---
AGENTS.md | 2 +-
app/controllers/organizations_controller.rb | 2 +-
app/models/organization.rb | 44 +++++++++++++++
app/views/organizations/_form.html.erb | 15 ++++++
app/views/organizations/show.html.erb | 21 ++++++++
config/features.yml | 13 +++++
...60831220947_add_parent_to_organizations.rb | 15 ++++++
db/schema.rb | 5 +-
docs/adr/0004-nested-organizations.md | 54 +++++++++++++++++++
spec/models/organization_spec.rb | 44 +++++++++++++++
spec/requests/organizations_spec.rb | 20 +++++++
11 files changed, 232 insertions(+), 3 deletions(-)
create mode 100644 db/migrate/20260831220947_add_parent_to_organizations.rb
create mode 100644 docs/adr/0004-nested-organizations.md
diff --git a/AGENTS.md b/AGENTS.md
index ee9a265414..7c5802aaca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -107,7 +107,7 @@ This codebase (Rails 8.1)
| `StaffTag` | Internal, admin-only label for people (talent pipeline / roster / outreach — "Potential future trainer", "DV Leadership Cohort"). Admin-CRUD'd; `Publishable` (`published` flag) retires a tag from the pickers without deleting; never shown publicly (`StaffTagPolicy` gates every action + relation scope). Applied via the polymorphic `StaffTagging` join (`StaffTaggable` concern, Person today). Starter set seeded in db/seeds.rb |
| `StaffTagging` | Polymorphic join linking a `StaffTag` to the record it tags (`staff_taggable`); `created_by`/`updated_by` (stamped from `Current.user`) record which admin applied and last touched it. Has its own admin edit page (`StaffTaggingsController`, `/staff_taggings/:id/edit`) rendering the shared comments & communications section (`Communicable` + `commentable`) and a tag reassignment select, with communications keyed on the taggable person's email; plus a searchable/filterable index (`/staff_taggings`, `search_by_params` over tag + tagged person's name/email/org) |
| `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured — that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. |
-| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
+| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage; self-nests under a parent org (`parent_id` self-FK, `#ancestors`/`#descendants`/`#root`, structural only) per ADR-0004 |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation`. Tri-state `agreement_response_status` (pending/accepted/declined) drives the agreement; declined awards zero their allocation and drop out of all totals |
| `ScholarshipAgreementResponse` | Append-only history of a scholarship's accept ↔ decline back-and-forth (status, reason, responder, amount at the time); the scholarship's `agreement_response_status` is the denormalized latest row, and `responded_at`/reason are read from the latest response, not stored on the scholarship |
diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb
index 3dd0dc50a0..1285ae73e2 100644
--- a/app/controllers/organizations_controller.rb
+++ b/app/controllers/organizations_controller.rb
@@ -242,7 +242,7 @@ def organization_params
params.require(:organization).permit(
:name, :description, :start_date, :end_date, :mission_vision_values,
:organization_type, :organization_type_other, :filemaker_code, :logo, :notes, :email, :website_url,
- :organization_status_id, :location_id, :windows_type_id, :high_profile,
+ :organization_status_id, :location_id, :windows_type_id, :high_profile, :parent_id,
:profile_show_sectors, :profile_show_age_ranges, :profile_show_email, :profile_show_phone,
:profile_show_website, :profile_show_description, :profile_show_workshops,
:profile_show_stories, :profile_show_events_registered, :profile_show_workshop_logs,
diff --git a/app/models/organization.rb b/app/models/organization.rb
index 76c3e66ac2..f224c336f5 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -7,6 +7,11 @@ class Organization < ApplicationRecord
belongs_to :organization_obligation, optional: true
belongs_to :location, optional: true # TODO - remove Location if unused
belongs_to :windows_type, optional: true
+ # Self-referential nesting: an org can sit under a parent "roof" org (mirrors
+ # FileMaker's Organization ParentID). Deleting a parent un-nests its children
+ # rather than cascading. See ADR-0004.
+ belongs_to :parent, class_name: "Organization", optional: true, inverse_of: :children
+ has_many :children, class_name: "Organization", foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify
has_many :addresses, as: :addressable, dependent: :destroy
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :other_responses, as: :owner, dependent: :destroy
@@ -68,6 +73,7 @@ def self.awbw
validates :website_url, length: { maximum: 255 }
validates :mission_vision_values, length: { maximum: 255 }
validate :affiliation_dates_locked, if: -> { affiliations.any? && !Current.user&.super_user? }
+ validate :parent_is_not_self_or_descendant, if: :parent_id_changed?
# Nested attributes
accepts_nested_attributes_for :addresses, allow_destroy: true,
@@ -240,6 +246,34 @@ def type_name
"#{name} #{ " (#{windows_type.short_name})" if windows_type}"
end
+ def nested?
+ parent_id.present?
+ end
+
+ # Every ancestor from the immediate parent up to the root, guarding against a
+ # malformed cycle so a bad row can't loop forever.
+ def ancestors
+ result = []
+ seen = [ id ]
+ current = parent
+ while current && seen.exclude?(current.id)
+ result << current
+ seen << current.id
+ current = current.parent
+ end
+ result
+ end
+
+ # The topmost org in this org's tree — itself when it has no parent.
+ def root
+ ancestors.last || self
+ end
+
+ # Every org nested under this one, at any depth.
+ def descendants
+ children.flat_map { |child| [ child ] + child.descendants }
+ end
+
def organization_description
locality = organization_locality
locality.present? ? "#{name}, #{locality}" : name
@@ -354,6 +388,16 @@ def affiliation_dates_locked
end
end
+ def parent_is_not_self_or_descendant
+ return if parent_id.blank?
+
+ if parent_id == id
+ errors.add(:parent_id, "can't be the organization itself")
+ elsif persisted? && descendants.any? { |org| org.id == parent_id }
+ errors.add(:parent_id, "can't be one of this organization's nested organizations")
+ end
+ end
+
def remove_duplicate_sectorable_items
sectorable_items
.group_by(&:sector_id)
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index 1e46c9ada8..dbfc54ca7f 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -168,6 +168,21 @@
<%= f.input :high_profile,
label: "High profile",
hint: "Shows a #{high_profile_gem} next to the org on dashboards and reports".html_safe %>
+ <% parent = f.object.parent %>
+ <%= f.input :parent_id,
+ label: "Parent organization",
+ collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
+ include_blank: "None",
+ hint: "Nest this org under a parent “roof” organization",
+ input_html: {
+ class: "w-full rounded-md border-gray-300 bg-blue-100 shadow-sm focus:ring-blue-500 focus:border-blue-500",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "organization",
+ remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
+ }
+ },
+ prompt: "Type to search organizations…" %>
<% end %>
diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb
index a9efa54736..2dda9b2788 100644
--- a/app/views/organizations/show.html.erb
+++ b/app/views/organizations/show.html.erb
@@ -69,6 +69,14 @@
<%= program_since %>
<% end %>
+ <% if @organization.parent.present? %>
+
+
+ Part of
+ <%= link_to @organization.parent.name, organization_path(@organization.parent),
+ class: "text-primary font-semibold hover:underline" %>
+
+ <% end %>
<% if @organization.profile_show_email? && @organization.email.present? %>
@@ -185,6 +193,19 @@
<% end %>
+
+ <% children = @organization.children.order(:name) %>
+ <% if children.any? %>
+
+
Nested organizations
+
+ <% children.each do |child| %>
+ <%= link_to child.name, organization_path(child), class: button_classes(:secondary_outline) %>
+ <% end %>
+
+
+ <% end %>
<% if @organization.profile_show_description? && @organization.description.present? %>
-
+ An organization can now sit under a parent “roof” organization — so a program
+ that used to be split into separate records (e.g. one for Adult Windows and
+ one for Children's Windows) can be grouped under one org.
+ pro_tips:
+ - "Set the parent from the “Parent organization” picker on the org edit page."
+ - "The profile shows a “Part of …” link up to the parent and a “Nested organizations” list down to the children."
+ - "Deleting a parent org un-nests its children rather than deleting them."
diff --git a/db/migrate/20260831220947_add_parent_to_organizations.rb b/db/migrate/20260831220947_add_parent_to_organizations.rb
new file mode 100644
index 0000000000..eaa0ab7c1e
--- /dev/null
+++ b/db/migrate/20260831220947_add_parent_to_organizations.rb
@@ -0,0 +1,15 @@
+class AddParentToOrganizations < ActiveRecord::Migration[7.2]
+ def up
+ add_column :organizations, :parent_id, :integer unless column_exists?(:organizations, :parent_id)
+ add_index :organizations, :parent_id unless index_exists?(:organizations, :parent_id)
+ unless foreign_key_exists?(:organizations, column: :parent_id)
+ add_foreign_key :organizations, :organizations, column: :parent_id
+ end
+ end
+
+ def down
+ remove_foreign_key :organizations, column: :parent_id if foreign_key_exists?(:organizations, column: :parent_id)
+ remove_index :organizations, :parent_id if index_exists?(:organizations, :parent_id)
+ remove_column :organizations, :parent_id if column_exists?(:organizations, :parent_id)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ed86d4c7ef..7ec3f1ebba 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_31_220445) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_31_220947) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -1188,6 +1188,7 @@
t.integer "organization_status_id"
t.string "organization_type"
t.string "organization_type_other"
+ t.integer "parent_id"
t.boolean "profile_show_age_ranges", default: true, null: false
t.boolean "profile_show_description", default: true, null: false
t.boolean "profile_show_email", default: true, null: false
@@ -1206,6 +1207,7 @@
t.index ["created_by_id"], name: "index_organizations_on_created_by_id"
t.index ["location_id"], name: "index_organizations_on_location_id"
t.index ["organization_status_id"], name: "index_organizations_on_organization_status_id"
+ t.index ["parent_id"], name: "index_organizations_on_parent_id"
t.index ["updated_by_id"], name: "index_organizations_on_updated_by_id"
t.index ["windows_type_id"], name: "index_organizations_on_windows_type_id"
end
@@ -2405,6 +2407,7 @@
add_foreign_key "organization_statuses", "users", column: "updated_by_id"
add_foreign_key "organizations", "locations"
add_foreign_key "organizations", "organization_statuses"
+ add_foreign_key "organizations", "organizations", column: "parent_id"
add_foreign_key "organizations", "users", column: "created_by_id"
add_foreign_key "organizations", "users", column: "updated_by_id"
add_foreign_key "organizations", "windows_types"
diff --git a/docs/adr/0004-nested-organizations.md b/docs/adr/0004-nested-organizations.md
new file mode 100644
index 0000000000..2222ee93de
--- /dev/null
+++ b/docs/adr/0004-nested-organizations.md
@@ -0,0 +1,54 @@
+# ADR-0004 — Nested organizations (a self-referential org tree)
+
+- **Status:** Accepted
+- **Date:** 2026-08-31
+
+## Context
+
+FileMaker is the source of truth we're importing from (rubyforgood/awbw#508).
+There, `Organization` records carry a `ParentID` self-reference and also own
+`Project` records. Staff historically split one real organization into two
+FileMaker Projects when it ran both Adult and Children's Windows programs; they
+now want those grouped "under one roof." When the FileMaker Organizations import
+lands, both the org→org parent chain and the org→project ownership collapse into
+the single Rails `Organization` model, so that model needs a way to nest.
+
+## Decisions
+
+### D1 — Adjacency-list tree on `organizations`
+
+Nesting is one nullable `organizations.parent_id` self-FK (`belongs_to :parent`,
+`has_many :children`), not a join table, closure table, or STI. It's the
+lightest seam that mirrors FileMaker's `ParentID` one-to-one and keeps re-parenting
+a single column write. Arbitrary depth is allowed (matching FileMaker), traversed
+with `#ancestors` / `#descendants` / `#root` helpers.
+
+### D2 — Structural only for now
+
+The parent/child link is navigation and grouping only. A parent org does **not**
+aggregate its children's affiliations, sectors, age groups, workshops, or events
+into its own profile/index rows. Roll-ups are a deliberate later change if the need
+appears; keeping the first cut structural avoids touching every aggregate scope and
+decorator before we know the import shape.
+
+### D3 — Deleting a parent un-nests its children
+
+`has_many :children, dependent: :nullify`. Removing a roof org must never cascade
+and destroy the real programs nested under it; the children survive as top-level
+orgs.
+
+### D4 — Cycle protection at the model
+
+`parent_id` can't be the org itself or one of its own descendants
+(`parent_is_not_self_or_descendant`). The traversal helpers also guard against a
+malformed stored cycle so a bad row can't loop forever.
+
+### D5 — Set the parent from the edit form
+
+Admins nest an org via a "Parent organization" remote-select picker on the org
+edit form (admin-gated, same `remote-select` pattern as the workshop picker), which
+excludes the org itself and its descendants. The profile shows the relationship
+read-only in both directions: a "Part of …" link up to the parent and a "Nested
+organizations" list down to the children. A dedicated "nest"/"demote" button is
+deferred — the picker plus two-way profile links already cover moving through the
+tree.
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb
index f40b364bdd..ac94934846 100644
--- a/spec/models/organization_spec.rb
+++ b/spec/models/organization_spec.rb
@@ -7,6 +7,8 @@
it { should belong_to(:location).optional }
it { should belong_to(:windows_type).optional }
it { should belong_to(:organization_status) }
+ it { should belong_to(:parent).class_name("Organization").optional }
+ it { should have_many(:children).class_name("Organization").with_foreign_key(:parent_id).dependent(:nullify) }
it { should have_many(:affiliations) }
it { should have_many(:users).through(:people) }
it { should have_many(:reports) }
@@ -31,6 +33,48 @@
end
end
+ describe "nesting" do
+ let(:parent) { create(:organization) }
+ let(:child) { create(:organization, parent: parent) }
+ let(:grandchild) { create(:organization, parent: child) }
+
+ it "exposes children and reports nested?" do
+ expect(parent.children).to include(child)
+ expect(child.nested?).to be(true)
+ expect(parent.nested?).to be(false)
+ end
+
+ it "walks ancestors up to the root" do
+ expect(grandchild.ancestors).to eq([ child, parent ])
+ expect(grandchild.root).to eq(parent)
+ expect(parent.root).to eq(parent)
+ end
+
+ it "collects descendants at any depth" do
+ grandchild
+ expect(parent.descendants).to match_array([ child, grandchild ])
+ end
+
+ it "un-nests children when the parent is destroyed" do
+ child
+ parent.destroy
+ expect(child.reload.parent_id).to be_nil
+ end
+
+ it "rejects making an org its own parent" do
+ parent.parent = parent
+ expect(parent).not_to be_valid
+ expect(parent.errors[:parent_id]).to be_present
+ end
+
+ it "rejects nesting an org under one of its own descendants" do
+ grandchild
+ parent.parent = grandchild
+ expect(parent).not_to be_valid
+ expect(parent.errors[:parent_id]).to be_present
+ end
+ end
+
describe "#website_link_url" do
it "prepends https:// to a bare domain" do
org = build(:organization, website_url: "awbw.org")
diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb
index e638da8e8f..bb8d6f1c64 100644
--- a/spec/requests/organizations_spec.rb
+++ b/spec/requests/organizations_spec.rb
@@ -60,6 +60,19 @@
expect(response).to be_successful
end
+ it "shows the parent link and nested children on the profile" do
+ parent = Organization.create!(valid_attributes.merge(name: "Roof Org"))
+ organization = Organization.create!(valid_attributes.merge(name: "Adult Windows"))
+ organization.update!(parent: parent)
+ child = Organization.create!(valid_attributes.merge(name: "Childrens Windows", parent: organization))
+
+ get organization_url(organization)
+ expect(response.body).to include("Part of")
+ expect(response.body).to include("Roof Org")
+ expect(response.body).to include("Nested organizations")
+ expect(response.body).to include(child.name)
+ end
+
it "shows age groups on the profile, gated by profile_show_age_ranges" do
organization = Organization.create!(valid_attributes)
age_type = create(:category_type, name: "AgeRange", published: true)
@@ -295,6 +308,13 @@
patch organization_url(organization), params: { organization: { high_profile: "1" } }
expect(organization.reload.high_profile).to be(true)
end
+
+ it "nests the organization under a parent" do
+ parent = Organization.create!(valid_attributes.merge(name: "Roof Org"))
+ organization = Organization.create!(valid_attributes)
+ patch organization_url(organization), params: { organization: { parent_id: parent.id } }
+ expect(organization.reload.parent).to eq(parent)
+ end
end
context "with invalid parameters" do
From 696002566d13a8f5a2ac4ad1b3ed753ac799647f Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 18:15:01 -0400
Subject: [PATCH 2/9] Add PR number to nested-orgs feature entry
Co-Authored-By: Claude Opus 4.8 (1M context)
---
config/features.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/config/features.yml b/config/features.yml
index e7636a891b..2ae227941e 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -3027,6 +3027,7 @@
area: people
display_status: admin_facing
released_on: 2026-08-31
+ pr_number: 2473
summary: >-
An organization can now sit under a parent “roof” organization — so a program
that used to be split into separate records (e.g. one for Adult Windows and
From c2f4e1226d312bee1f98d88b7056bf690a99080a Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 23:22:24 -0400
Subject: [PATCH 3/9] Move parent org picker above Windows audience
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/views/organizations/_form.html.erb | 32 ++++++++++++++------------
1 file changed, 17 insertions(+), 15 deletions(-)
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index dbfc54ca7f..167e057a29 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -54,6 +54,23 @@
+ <% if allowed_to?(:manage?, Organization) %>
+ <% parent = f.object.parent %>
+ <%= f.input :parent_id,
+ label: "Parent organization",
+ collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
+ include_blank: "None",
+ hint: "Nest this org under a parent “roof” organization",
+ input_html: {
+ class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "organization",
+ remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
+ }
+ },
+ prompt: "Type to search organizations…" %>
+ <% end %>
<%= f.association :windows_type,
label: "Windows audience",
include_blank: true,
@@ -168,21 +185,6 @@
<%= f.input :high_profile,
label: "High profile",
hint: "Shows a #{high_profile_gem} next to the org on dashboards and reports".html_safe %>
- <% parent = f.object.parent %>
- <%= f.input :parent_id,
- label: "Parent organization",
- collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
- include_blank: "None",
- hint: "Nest this org under a parent “roof” organization",
- input_html: {
- class: "w-full rounded-md border-gray-300 bg-blue-100 shadow-sm focus:ring-blue-500 focus:border-blue-500",
- data: {
- controller: "remote-select",
- remote_select_model_value: "organization",
- remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
- }
- },
- prompt: "Type to search organizations…" %>
<% end %>
From e65ccd774b181fdc16266e97314ce8290ebf6f07 Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 23:24:07 -0400
Subject: [PATCH 4/9] Put parent org picker in the same row as the org name
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/views/organizations/_form.html.erb | 40 +++++++++++++-------------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index 167e057a29..99855742c1 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -11,9 +11,9 @@
-
-
-
+
+
">
+
<%= f.input :name,
label: "Organization Name",
as: :text,
@@ -25,6 +25,23 @@
focus:border-blue-500 focus:ring focus:ring-blue-200"
} %>
+ <% if allowed_to?(:manage?, Organization) %>
+ <% parent = f.object.parent %>
+ <%= f.input :parent_id,
+ label: "Parent organization",
+ collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
+ include_blank: "None",
+ hint: "Nest this org under a parent “roof” organization",
+ input_html: {
+ class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "organization",
+ remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
+ }
+ },
+ prompt: "Type to search organizations…" %>
+ <% end %>
@@ -54,23 +71,6 @@
- <% if allowed_to?(:manage?, Organization) %>
- <% parent = f.object.parent %>
- <%= f.input :parent_id,
- label: "Parent organization",
- collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
- include_blank: "None",
- hint: "Nest this org under a parent “roof” organization",
- input_html: {
- class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500",
- data: {
- controller: "remote-select",
- remote_select_model_value: "organization",
- remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
- }
- },
- prompt: "Type to search organizations…" %>
- <% end %>
<%= f.association :windows_type,
label: "Windows audience",
include_blank: true,
From a3f79033fd77d9eb9e08102172e95cffe0e2b194 Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 23:25:33 -0400
Subject: [PATCH 5/9] Shorten parent org hint to one line
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/views/organizations/_form.html.erb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index 99855742c1..cc23c3c6eb 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -31,7 +31,7 @@
label: "Parent organization",
collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
include_blank: "None",
- hint: "Nest this org under a parent “roof” organization",
+ hint: "Nest under a parent “roof” org",
input_html: {
class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500",
data: {
From 1b5a7b93a9e02d089bc679ec43421cb95a27839d Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 23:36:03 -0400
Subject: [PATCH 6/9] Truncate long selected label in remote-select instead of
wrapping
Applies to every remote-select record picker: a long value (e.g. a parent
org name) now ellipsizes on one line rather than doubling the control height.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../javascript/controllers/remote_select_controller.js | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/frontend/javascript/controllers/remote_select_controller.js b/app/frontend/javascript/controllers/remote_select_controller.js
index ce69d403e7..1c65905842 100644
--- a/app/frontend/javascript/controllers/remote_select_controller.js
+++ b/app/frontend/javascript/controllers/remote_select_controller.js
@@ -94,6 +94,13 @@ export default class extends Controller {
margin: 0 !important; /* Remove padding/margin from selected items */
padding: 0 !important;
line-height: 1.5rem !important;
+ /* Truncate a long selected label to one line instead of wrapping and
+ doubling the control's height. min-width:0 lets it shrink in the flex row. */
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
/* Opaque white field so colored card backgrounds don't show through */
.remote-select-container .ts-wrapper,
From b9f1db0989eb7c952465eb8d0fad2709fdffec84 Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 23:46:42 -0400
Subject: [PATCH 7/9] Scope remote-select truncation to an opt-in truncate
value
Only the parent-org picker truncates its long selected label; every other
remote-select keeps its existing wrapping behavior.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../javascript/controllers/remote_select_controller.js | 10 +++++++---
app/views/organizations/_form.html.erb | 3 ++-
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/app/frontend/javascript/controllers/remote_select_controller.js b/app/frontend/javascript/controllers/remote_select_controller.js
index 1c65905842..766bb83f40 100644
--- a/app/frontend/javascript/controllers/remote_select_controller.js
+++ b/app/frontend/javascript/controllers/remote_select_controller.js
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
export default class extends Controller {
- static values = { model: String, exclude: String };
+ static values = { model: String, exclude: String, truncate: Boolean };
connect() {
// TomSelect stamps itself on the element; bail if it's already initialized so
@@ -94,8 +94,11 @@ export default class extends Controller {
margin: 0 !important; /* Remove padding/margin from selected items */
padding: 0 !important;
line-height: 1.5rem !important;
- /* Truncate a long selected label to one line instead of wrapping and
- doubling the control's height. min-width:0 lets it shrink in the flex row. */
+ }
+ /* Opt-in (truncate value): ellipsize a long selected label to one line
+ instead of wrapping and doubling the control's height. min-width:0 lets
+ it shrink in the flex row. */
+ .remote-select-truncate .ts-control .item {
min-width: 0;
max-width: 100%;
overflow: hidden;
@@ -116,6 +119,7 @@ export default class extends Controller {
if (!wrapper || wrapper.parentElement?.classList.contains("remote-select-container")) return;
const container = document.createElement("div");
container.className = "remote-select-container";
+ container.classList.toggle("remote-select-truncate", this.truncateValue);
wrapper.parentNode.insertBefore(container, wrapper);
container.appendChild(wrapper);
const icon = document.createElement("i");
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index cc23c3c6eb..dffc331374 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -37,7 +37,8 @@
data: {
controller: "remote-select",
remote_select_model_value: "organization",
- remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
+ remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(","),
+ remote_select_truncate_value: true
}
},
prompt: "Type to search organizations…" %>
From 9a21cef9eca01b2738098f2a68d402e45b45969c Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Mon, 31 Aug 2026 23:53:05 -0400
Subject: [PATCH 8/9] Cap parent-org picker width, tooltip full value, link
label to parent
- remote-select truncation is now a fixed ch-width cap (truncate-chars value)
driving a CSS var, never exceeding the control width
- selected item carries a title tooltip so the full label shows on hover
- when a parent is saved, the picker label becomes a jump link (new tab) to
the parent org's profile
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../controllers/remote_select_controller.js | 20 +++++++++++++------
app/views/organizations/_form.html.erb | 13 ++++++++++--
spec/requests/organizations_spec.rb | 8 ++++++++
3 files changed, 33 insertions(+), 8 deletions(-)
diff --git a/app/frontend/javascript/controllers/remote_select_controller.js b/app/frontend/javascript/controllers/remote_select_controller.js
index 766bb83f40..72d5bdc6bd 100644
--- a/app/frontend/javascript/controllers/remote_select_controller.js
+++ b/app/frontend/javascript/controllers/remote_select_controller.js
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
export default class extends Controller {
- static values = { model: String, exclude: String, truncate: Boolean };
+ static values = { model: String, exclude: String, truncateChars: Number };
connect() {
// TomSelect stamps itself on the element; bail if it's already initialized so
@@ -16,6 +16,10 @@ export default class extends Controller {
searchField: "label",
score: () => () => 1,
create: false,
+ // Native title tooltip so a truncated selected label still shows in full on hover.
+ render: {
+ item: (data, escape) => `${escape(data.label)}
`
+ },
load: (query, callback) => {
if (!query.length) return callback();
@@ -95,12 +99,13 @@ export default class extends Controller {
padding: 0 !important;
line-height: 1.5rem !important;
}
- /* Opt-in (truncate value): ellipsize a long selected label to one line
- instead of wrapping and doubling the control's height. min-width:0 lets
- it shrink in the flex row. */
+ /* Opt-in (truncate-chars value): cap a long selected label at a fixed width
+ (--remote-select-truncate, in ch) and ellipsize it to one line instead of
+ wrapping and doubling the control's height. min-width:0 lets it shrink in
+ the flex row; it never exceeds the control's own width. */
.remote-select-truncate .ts-control .item {
min-width: 0;
- max-width: 100%;
+ max-width: min(var(--remote-select-truncate, 100%), 100%);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -119,7 +124,10 @@ export default class extends Controller {
if (!wrapper || wrapper.parentElement?.classList.contains("remote-select-container")) return;
const container = document.createElement("div");
container.className = "remote-select-container";
- container.classList.toggle("remote-select-truncate", this.truncateValue);
+ if (this.truncateCharsValue > 0) {
+ container.classList.add("remote-select-truncate");
+ container.style.setProperty("--remote-select-truncate", `${this.truncateCharsValue}ch`);
+ }
wrapper.parentNode.insertBefore(container, wrapper);
container.appendChild(wrapper);
const icon = document.createElement("i");
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index dffc331374..48f121d50a 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -27,8 +27,17 @@
<% if allowed_to?(:manage?, Organization) %>
<% parent = f.object.parent %>
+ <% parent_label = if parent
+ link_to organization_path(parent), target: "_blank", rel: "noopener",
+ title: "View #{parent.name}",
+ class: "inline-flex items-center gap-1 hover:underline #{DomainTheme.text_class_for(:organizations, intensity: 700)}" do
+ safe_join([ "Parent organization", tag.i(class: "fa-solid fa-arrow-up-right-from-square text-xs") ])
+ end
+ else
+ "Parent organization"
+ end %>
<%= f.input :parent_id,
- label: "Parent organization",
+ label: parent_label,
collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
include_blank: "None",
hint: "Nest under a parent “roof” org",
@@ -38,7 +47,7 @@
controller: "remote-select",
remote_select_model_value: "organization",
remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(","),
- remote_select_truncate_value: true
+ remote_select_truncate_chars_value: 22
}
},
prompt: "Type to search organizations…" %>
diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb
index bb8d6f1c64..0f73e9eba6 100644
--- a/spec/requests/organizations_spec.rb
+++ b/spec/requests/organizations_spec.rb
@@ -181,6 +181,14 @@
expect(response.body).not_to include("Monthly reports")
end
+ it "links the parent-organization label to the parent's profile when one is set" do
+ parent = Organization.create!(valid_attributes.merge(name: "Roof Org"))
+ organization = Organization.create!(valid_attributes.merge(parent: parent))
+ get edit_organization_url(organization)
+ expect(response.body).to include(organization_path(parent))
+ expect(response.body).to include("fa-arrow-up-right-from-square")
+ end
+
it "shows the Monthly reports row when monthly reports exist" do
organization = Organization.create!(valid_attributes)
create(:monthly_report, organization: organization)
From c163fafe476ee1a3805b680f746e0e5323ccf56d Mon Sep 17 00:00:00 2001
From: Mae Beale
Date: Tue, 1 Sep 2026 12:06:50 -0400
Subject: [PATCH 9/9] Revert remote-select styling changes; keep parent label
jump link
Drop the truncation/tooltip changes to the shared remote-select controller;
the parent-org picker no longer opts into a width cap. The clickable
jump-link label to the parent's profile stays.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../controllers/remote_select_controller.js | 21 +------------------
app/views/organizations/_form.html.erb | 3 +--
2 files changed, 2 insertions(+), 22 deletions(-)
diff --git a/app/frontend/javascript/controllers/remote_select_controller.js b/app/frontend/javascript/controllers/remote_select_controller.js
index 72d5bdc6bd..ce69d403e7 100644
--- a/app/frontend/javascript/controllers/remote_select_controller.js
+++ b/app/frontend/javascript/controllers/remote_select_controller.js
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
export default class extends Controller {
- static values = { model: String, exclude: String, truncateChars: Number };
+ static values = { model: String, exclude: String };
connect() {
// TomSelect stamps itself on the element; bail if it's already initialized so
@@ -16,10 +16,6 @@ export default class extends Controller {
searchField: "label",
score: () => () => 1,
create: false,
- // Native title tooltip so a truncated selected label still shows in full on hover.
- render: {
- item: (data, escape) => `${escape(data.label)}
`
- },
load: (query, callback) => {
if (!query.length) return callback();
@@ -99,17 +95,6 @@ export default class extends Controller {
padding: 0 !important;
line-height: 1.5rem !important;
}
- /* Opt-in (truncate-chars value): cap a long selected label at a fixed width
- (--remote-select-truncate, in ch) and ellipsize it to one line instead of
- wrapping and doubling the control's height. min-width:0 lets it shrink in
- the flex row; it never exceeds the control's own width. */
- .remote-select-truncate .ts-control .item {
- min-width: 0;
- max-width: min(var(--remote-select-truncate, 100%), 100%);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
/* Opaque white field so colored card backgrounds don't show through */
.remote-select-container .ts-wrapper,
.remote-select-container .ts-control {
@@ -124,10 +109,6 @@ export default class extends Controller {
if (!wrapper || wrapper.parentElement?.classList.contains("remote-select-container")) return;
const container = document.createElement("div");
container.className = "remote-select-container";
- if (this.truncateCharsValue > 0) {
- container.classList.add("remote-select-truncate");
- container.style.setProperty("--remote-select-truncate", `${this.truncateCharsValue}ch`);
- }
wrapper.parentNode.insertBefore(container, wrapper);
container.appendChild(wrapper);
const icon = document.createElement("i");
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index 48f121d50a..217f56f7ae 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -46,8 +46,7 @@
data: {
controller: "remote-select",
remote_select_model_value: "organization",
- remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(","),
- remote_select_truncate_chars_value: 22
+ remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
}
},
prompt: "Type to search organizations…" %>