diff --git a/app/views/custom_org_links/_form.html.erb b/app/views/custom_org_links/_form.html.erb
new file mode 100644
index 0000000000..3dad97b992
--- /dev/null
+++ b/app/views/custom_org_links/_form.html.erb
@@ -0,0 +1,42 @@
+
+
+
+
+ <%= form_with model: @custom_org_link, local: true do |form| %>
+
+ <%= render "/shared/error_messages", resource: @custom_org_link %>
+
+
+
+ <%= form.label :name, "Display Text" %>
+ <%= form.text_field :text, class: "form-control", required: true %>
+
+
+
+ <%= form.label :name, "URL" %>
+ <%= form.text_field :url, class: "form-control", required: true %>
+
+
+
+ <%= form.check_box :active, as: :boolean, class: 'form-check-input' %>
+ <%= form.label :active, "Active?", class: 'form-check-label' %>
+
+
+
+ <%= button_tag type: "submit", class: "btn-sm main-btn primary-btn btn-hover" do %>
+ <%= action %>
+ <% end %>
+
+ <% end %>
+
+
diff --git a/app/views/custom_org_links/edit.html.erb b/app/views/custom_org_links/edit.html.erb
new file mode 100644
index 0000000000..e1e17f11ee
--- /dev/null
+++ b/app/views/custom_org_links/edit.html.erb
@@ -0,0 +1 @@
+<%= render partial: "form", locals: { title: "Edit Custom Link", action: 'Update' } %>
diff --git a/app/views/custom_org_links/new.html.erb b/app/views/custom_org_links/new.html.erb
new file mode 100644
index 0000000000..1293855a70
--- /dev/null
+++ b/app/views/custom_org_links/new.html.erb
@@ -0,0 +1 @@
+<%= render partial: "form", locals: { title: "New Custom Link", action: 'Create' } %>
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb
index 592349509d..cc3eecfd67 100644
--- a/app/views/layouts/_sidebar.html.erb
+++ b/app/views/layouts/_sidebar.html.erb
@@ -60,6 +60,12 @@
{ title: "Case Contact Topics", icon: "comments", path: edit_casa_org_path(current_organization, anchor: 'case-contact-topics'), render_check: policy(:application).modify_organization? }
]) %>
<% end %>
+ <% if current_organization.custom_org_links.any?(&:active) %>
+
+ <% current_organization.custom_org_links.select(&:active).each do |custom_link| %>
+ <%= render(Sidebar::LinkComponent.new(title: custom_link.text, icon: "link", path: custom_link.url)) %>
+ <% end %>
+ <% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 7344e21169..7afc4de49d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -235,6 +235,7 @@
resources :languages, only: %i[new create edit update] do
delete :remove_from_volunteer
end
+ resources :custom_org_links, only: %i[new create edit update destroy]
direct :help_admins_supervisors do
"https://thunder-flower-8c2.notion.site/Casa-Volunteer-Tracking-App-HelpSite-3b95705e80c742ffa729ccce7beeabfa"
diff --git a/db/migrate/20250404200715_create_custom_org_links.rb b/db/migrate/20250404200715_create_custom_org_links.rb
new file mode 100644
index 0000000000..512ac0c4a8
--- /dev/null
+++ b/db/migrate/20250404200715_create_custom_org_links.rb
@@ -0,0 +1,11 @@
+class CreateCustomOrgLinks < ActiveRecord::Migration[7.2]
+ def change
+ create_table :custom_org_links do |t|
+ t.references :casa_org, null: false, foreign_key: true
+ t.string :text, null: false
+ t.string :url, null: false
+ t.boolean :active, null: false, default: true
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a3504873e9..a9fd2c7f61 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -300,6 +300,16 @@
t.index ["casa_case_id"], name: "index_court_dates_on_casa_case_id"
end
+ create_table "custom_org_links", force: :cascade do |t|
+ t.bigint "casa_org_id", null: false
+ t.string "text", null: false
+ t.string "url", null: false
+ t.boolean "active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["casa_org_id"], name: "index_custom_org_links_on_casa_org_id"
+ end
+
create_table "delayed_jobs", force: :cascade do |t|
t.integer "priority", default: 0, null: false
t.integer "attempts", default: 0, null: false
@@ -682,6 +692,7 @@
add_foreign_key "contact_topic_answers", "contact_topics"
add_foreign_key "contact_topics", "casa_orgs"
add_foreign_key "court_dates", "casa_cases"
+ add_foreign_key "custom_org_links", "casa_orgs"
add_foreign_key "emancipation_options", "emancipation_categories"
add_foreign_key "followups", "users", column: "creator_id"
add_foreign_key "judges", "casa_orgs"
diff --git a/spec/factories/custom_org_links.rb b/spec/factories/custom_org_links.rb
new file mode 100644
index 0000000000..3be8c7548f
--- /dev/null
+++ b/spec/factories/custom_org_links.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :custom_org_link do
+ casa_org
+ text { "Custom Link Text" }
+ url { "https://custom.link" }
+ active { true }
+ end
+end
diff --git a/spec/models/casa_org_spec.rb b/spec/models/casa_org_spec.rb
index 2ff6357ab8..1eccf8d7d7 100644
--- a/spec/models/casa_org_spec.rb
+++ b/spec/models/casa_org_spec.rb
@@ -12,6 +12,7 @@
it { is_expected.to have_one_attached(:logo) }
it { is_expected.to have_one_attached(:court_report_template) }
it { is_expected.to have_many(:contact_topics) }
+ it { is_expected.to have_many(:custom_org_links).dependent(:destroy) }
it "has unique name" do
org = create(:casa_org)
diff --git a/spec/models/custom_org_link_spec.rb b/spec/models/custom_org_link_spec.rb
new file mode 100644
index 0000000000..089964d3d7
--- /dev/null
+++ b/spec/models/custom_org_link_spec.rb
@@ -0,0 +1,18 @@
+require "rails_helper"
+
+RSpec.describe CustomOrgLink, type: :model do
+ it { is_expected.to belong_to :casa_org }
+ it { is_expected.to validate_presence_of :text }
+ it { is_expected.to validate_presence_of :url }
+ it { is_expected.to validate_length_of(:text).is_at_most described_class::TEXT_MAX_LENGTH }
+ it { is_expected.to validate_inclusion_of(:active).in_array [true, false] }
+
+ describe "url validation - only allow http or https schemes" do
+ it { is_expected.to allow_value("http://example.com").for(:url) }
+ it { is_expected.to allow_value("https://example.com").for(:url) }
+
+ it { is_expected.not_to allow_value("ftp://example.com").for(:url) }
+ it { is_expected.not_to allow_value("example.com").for(:url) }
+ it { is_expected.not_to allow_value("some arbitrary string").for(:url) }
+ end
+end
diff --git a/spec/policies/custom_org_link_policy_spec.rb b/spec/policies/custom_org_link_policy_spec.rb
new file mode 100644
index 0000000000..2879a5ffb3
--- /dev/null
+++ b/spec/policies/custom_org_link_policy_spec.rb
@@ -0,0 +1,21 @@
+require "rails_helper"
+
+RSpec.describe CustomOrgLinkPolicy, type: :policy do
+ let(:casa_admin) { build_stubbed(:casa_admin) }
+ let(:supervisor) { build_stubbed(:supervisor) }
+ let(:volunteer) { build_stubbed(:volunteer) }
+
+ permissions :new?, :create?, :edit?, :update? do
+ it "permits casa_admins" do
+ expect(described_class).to permit(casa_admin)
+ end
+
+ it "does not permit supervisor" do
+ expect(described_class).not_to permit(supervisor)
+ end
+
+ it "does not permit volunteer" do
+ expect(described_class).not_to permit(volunteer)
+ end
+ end
+end
diff --git a/spec/requests/custom_org_links_spec.rb b/spec/requests/custom_org_links_spec.rb
new file mode 100644
index 0000000000..c496721468
--- /dev/null
+++ b/spec/requests/custom_org_links_spec.rb
@@ -0,0 +1,161 @@
+require "rails_helper"
+
+RSpec.describe "/custom_org_links", type: :request do
+ let(:casa_org) { create(:casa_org) }
+ let(:casa_admin) { create :casa_admin, casa_org: casa_org }
+ let(:volunteer) { create :volunteer, casa_org: casa_org, active: true }
+
+ describe "GET /custom_org_links/new" do
+ context "when logged in as admin user" do
+ before { sign_in casa_admin }
+
+ it "can successfully access a custom org link create page" do
+ get new_custom_org_link_path
+ expect(response).to be_successful
+ end
+ end
+
+ context "when logged in as a non-admin user" do
+ before { sign_in volunteer }
+
+ it "cannot access a custom org link create page" do
+ get new_custom_org_link_path
+ expect(response).to redirect_to root_path
+ expect(response.request.flash[:notice]).to eq "Sorry, you are not authorized to perform this action."
+ end
+ end
+
+ context "when not logged in" do
+ it "cannot access a custom org link create page" do
+ get new_custom_org_link_path
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+ end
+
+ describe "POST /custom_org_links" do
+ let(:params) { {custom_org_link: {text: "New Custom Link", url: "http://www.custom.link", active: true}} }
+
+ context "when logged in as admin user" do
+ let(:expected_custom_link_attributes) { params[:custom_org_link].merge(casa_org_id: casa_org.id).stringify_keys }
+ before { sign_in casa_admin }
+
+ it "can successfully create a custom org link" do
+ expect { post custom_org_links_path, params: params }.to change { CustomOrgLink.count }.by(1)
+ expect(CustomOrgLink.last.attributes).to include(**expected_custom_link_attributes)
+ expect(response).to redirect_to edit_casa_org_path(casa_org)
+ expect(response.request.flash[:notice]).to eq "Custom link was successfully created."
+ end
+ end
+
+ context "when logged in as a non-admin user" do
+ before { sign_in volunteer }
+
+ it "cannot create a custom org link" do
+ post custom_org_links_path, params: params
+ expect(response).to redirect_to root_path
+ expect(response.request.flash[:notice]).to eq "Sorry, you are not authorized to perform this action."
+ end
+ end
+
+ context "when not logged in" do
+ it "cannot create a custom org link" do
+ post custom_org_links_path, params: params
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+ end
+
+ describe "GET /custom_org_links/:id/edit" do
+ context "when logged in as admin user" do
+ before { sign_in_as_admin }
+
+ it "can successfully access a contact type edit page" do
+ get edit_custom_org_link_path(create(:custom_org_link))
+ expect(response).to be_successful
+ end
+ end
+
+ context "when logged in as a non-admin user" do
+ before { sign_in_as_volunteer }
+
+ it "cannot access a contact type edit page" do
+ get edit_custom_org_link_path(create(:custom_org_link))
+ expect(response).to redirect_to root_path
+ expect(response.request.flash[:notice]).to eq "Sorry, you are not authorized to perform this action."
+ end
+ end
+
+ context "when not logged in" do
+ it "cannot access a contact type edit page" do
+ get edit_custom_org_link_path(create(:custom_org_link))
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+ end
+
+ describe "PUT /custom_org_links/:id" do
+ let!(:custom_org_link) { create :custom_org_link, casa_org: casa_org, text: "Existing Link", url: "http://existing.com", active: false }
+ let(:params) { {custom_org_link: {text: "New Custom Link", url: "http://www.custom.link", active: true}} }
+
+ context "when logged in as admin user" do
+ let(:expected_custom_link_attributes) { params[:custom_org_link].merge(casa_org_id: casa_org.id).stringify_keys }
+ before { sign_in casa_admin }
+
+ it "can successfully update a custom org link" do
+ expect { put custom_org_link_path(custom_org_link), params: params }.to_not change { CustomOrgLink.count }
+ expect(custom_org_link.reload.attributes).to include(**expected_custom_link_attributes)
+ expect(response).to redirect_to edit_casa_org_path(casa_org)
+ expect(response.request.flash[:notice]).to eq "Custom link was successfully updated."
+ end
+ end
+
+ context "when logged in as a non-admin user" do
+ before { sign_in volunteer }
+
+ it "cannot update a custom org link" do
+ put custom_org_link_path(custom_org_link), params: params
+ expect(response).to redirect_to root_path
+ expect(response.request.flash[:notice]).to eq "Sorry, you are not authorized to perform this action."
+ end
+ end
+
+ context "when not logged in" do
+ it "cannot update a custom org link" do
+ put custom_org_link_path(custom_org_link), params: params
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+ end
+
+ describe "DELETE /custom_org_links/:id" do
+ let!(:custom_org_link) { create :custom_org_link, casa_org: casa_org, text: "Existing Link", url: "http://existing.com", active: false }
+
+ context "when logged in as admin user" do
+ before { sign_in casa_admin }
+
+ it "can successfully delete a custom org link" do
+ expect { delete custom_org_link_path(custom_org_link) }.to change { CustomOrgLink.count }.by(-1)
+ expect(response).to redirect_to edit_casa_org_path(casa_org)
+ expect(response.request.flash[:notice]).to eq "Custom link was successfully deleted."
+ end
+ end
+
+ context "when logged in as a non-admin user" do
+ before { sign_in volunteer }
+
+ it "cannot delete a custom org link" do
+ delete custom_org_link_path(custom_org_link)
+ expect(response).to redirect_to root_path
+ expect(response.request.flash[:notice]).to eq "Sorry, you are not authorized to perform this action."
+ end
+ end
+
+ context "when not logged in" do
+ it "cannot delete a custom org link" do
+ delete custom_org_link_path(custom_org_link)
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+ end
+end
diff --git a/spec/views/casa_orgs/edit.html.erb_spec.rb b/spec/views/casa_orgs/edit.html.erb_spec.rb
index 9e299446d2..49db424b2f 100644
--- a/spec/views/casa_orgs/edit.html.erb_spec.rb
+++ b/spec/views/casa_orgs/edit.html.erb_spec.rb
@@ -10,6 +10,7 @@
assign(:learning_hour_topics, [])
assign(:sent_emails, [])
assign(:contact_topics, [])
+ assign(:custom_org_links, [])
sign_in build_stubbed(:casa_admin)
end
@@ -170,4 +171,36 @@
end
end
end
+
+ describe "custom org links" do
+ let(:casa_org) { build_stubbed :casa_org }
+ before { allow(view).to receive(:current_organization).and_return(casa_org) }
+
+ it "has custom org link content" do
+ render template: "casa_org/edit"
+ expect(rendered).to have_text("Custom Links")
+ end
+
+ context "when the org has no custom links" do
+ before { assign(:custom_org_links, []) }
+
+ it "includes a helpful message" do
+ render template: "casa_org/edit"
+ expect(rendered).to have_text("No custom links have been added for this organization.")
+ end
+ end
+
+ context "when the org has custom links" do
+ let(:link_text) { "Example Link" }
+ let(:link_url) { "https://www.example.com" }
+ let(:custom_org_link) { build_stubbed :custom_org_link, text: link_text, url: link_url }
+ before { assign(:custom_org_links, [custom_org_link]) }
+
+ it "has custom link details" do
+ render template: "casa_org/edit"
+ expect(rendered).to have_text link_text
+ expect(rendered).to have_text link_url
+ end
+ end
+ end
end
diff --git a/spec/views/layouts/sidebar.html.erb_spec.rb b/spec/views/layouts/sidebar.html.erb_spec.rb
index 93e4e965e9..2205718fc7 100644
--- a/spec/views/layouts/sidebar.html.erb_spec.rb
+++ b/spec/views/layouts/sidebar.html.erb_spec.rb
@@ -14,6 +14,36 @@
assign :casa_org, user.casa_org
end
+ shared_examples_for "properly rendering custom org links" do
+ let(:active_link_text) { "Example Link" }
+ let(:active_link_url) { "https://www.example.com" }
+ let(:inactive_link_text) { "Hidden Link" }
+ let(:inactive_link_url) { "https://www.nothing.com" }
+ let(:other_org_link_text) { "That Other Link" }
+ let(:other_org_link_url) { "https://www.elsewhere.com" }
+
+ before do
+ create :custom_org_link, casa_org: user.casa_org, text: active_link_text, url: active_link_url, active: true
+ create :custom_org_link, casa_org: user.casa_org, text: inactive_link_text, url: inactive_link_url, active: false
+ create :custom_org_link, text: other_org_link_text, url: other_org_link_url, active: true
+ end
+
+ it "renders active custom links for the user's org" do
+ render partial: "layouts/sidebar"
+ expect(rendered).to have_link(active_link_text, href: active_link_url)
+ end
+
+ it "does not render inactive custom links for the user's org" do
+ render partial: "layouts/sidebar"
+ expect(rendered).not_to have_link(inactive_link_text, href: inactive_link_url)
+ end
+
+ it "does not render custom links for other orgs" do
+ render partial: "layouts/sidebar"
+ expect(rendered).not_to have_link(other_org_link_text, href: other_org_link_url)
+ end
+ end
+
context "when no organization logo is set" do
let(:user) { build_stubbed :volunteer }
@@ -54,6 +84,8 @@
expect(rendered).not_to have_link("Case Contact Topics", href: "/casa_org/#{user.casa_org.id}/edit#case-contact-topics")
end
+ it_behaves_like "properly rendering custom org links"
+
context "when casa_org other_duties_enabled is true" do
before do
user.casa_org.other_duties_enabled = true
@@ -112,6 +144,8 @@
expect(rendered).not_to have_link("Case Contact Topics", href: "/casa_org/#{user.casa_org.id}/edit#case-contact-topics")
end
+ it_behaves_like "properly rendering custom org links"
+
context "when casa_org other_duties_enabled is true" do
before do
user.casa_org.other_duties_enabled = true
@@ -207,6 +241,8 @@
expect(rendered).to have_link("Case Contact Topics", href: "/casa_org/#{user.casa_org.id}/edit#case-contact-topics")
end
+ it_behaves_like "properly rendering custom org links"
+
context "when casa_org other_duties_enabled is true" do
before do
user.casa_org.other_duties_enabled = true