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…" %>