diff --git a/.allow_skipping_tests b/.allow_skipping_tests index b024fa1cbb..08d665376b 100644 --- a/.allow_skipping_tests +++ b/.allow_skipping_tests @@ -45,6 +45,7 @@ services/create_casa_admin_service.rb services/fdf_inputs_service.rb validators/casa_org_validator.rb validators/court_report_validator.rb +validators/url_validator.rb validators/user_validator.rb values/all_casa_admin_parameters.rb values/casa_admin_parameters.rb diff --git a/app/controllers/casa_org_controller.rb b/app/controllers/casa_org_controller.rb index 16bc3d7701..fbc0015265 100644 --- a/app/controllers/casa_org_controller.rb +++ b/app/controllers/casa_org_controller.rb @@ -7,6 +7,7 @@ class CasaOrgController < ApplicationController before_action :set_learning_hour_topics, only: %i[edit update] before_action :set_sent_emails, only: %i[edit update] before_action :set_contact_topics, only: %i[edit update] + before_action :set_custom_org_links, only: %i[edit update] before_action :require_organization! after_action :verify_authorized before_action :set_active_storage_url_options, only: %i[edit update] @@ -90,6 +91,10 @@ def set_contact_topics @contact_topics = @casa_org.contact_topics.where(soft_delete: false) end + def set_custom_org_links + @custom_org_links = @casa_org.custom_org_links + end + def set_active_storage_url_options ActiveStorage::Current.url_options = {host: request.base_url} end diff --git a/app/controllers/custom_org_links_controller.rb b/app/controllers/custom_org_links_controller.rb new file mode 100644 index 0000000000..a49b22c8f3 --- /dev/null +++ b/app/controllers/custom_org_links_controller.rb @@ -0,0 +1,50 @@ +class CustomOrgLinksController < ApplicationController + before_action :set_custom_org_link, only: %i[edit update destroy] + after_action :verify_authorized + + def new + authorize CustomOrgLink + @custom_org_link = CustomOrgLink.new + end + + def create + authorize CustomOrgLink + + @custom_org_link = current_organization.custom_org_links.new(custom_org_link_params) + + if @custom_org_link.save + redirect_to edit_casa_org_path(current_organization), notice: "Custom link was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @custom_org_link + end + + def update + authorize @custom_org_link + if @custom_org_link.update(custom_org_link_params) + redirect_to edit_casa_org_path(current_organization), notice: "Custom link was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @custom_org_link + @custom_org_link.destroy + redirect_to edit_casa_org_path(current_organization), notice: "Custom link was successfully deleted." + end + + private + + def set_custom_org_link + @custom_org_link = CustomOrgLink.find(params[:id]) + end + + def custom_org_link_params + params.require(:custom_org_link).permit(:text, :url, :active) + end +end diff --git a/app/models/casa_org.rb b/app/models/casa_org.rb index 5c98594cda..e6a942f58b 100644 --- a/app/models/casa_org.rb +++ b/app/models/casa_org.rb @@ -27,6 +27,7 @@ class CasaOrg < ApplicationRecord has_many :learning_hour_topics, dependent: :destroy has_many :case_groups, dependent: :destroy has_many :contact_topics + has_many :custom_org_links, dependent: :destroy has_one_attached :logo has_one_attached :court_report_template has_many :placement_types, dependent: :destroy diff --git a/app/models/custom_org_link.rb b/app/models/custom_org_link.rb new file mode 100644 index 0000000000..7181066b58 --- /dev/null +++ b/app/models/custom_org_link.rb @@ -0,0 +1,30 @@ +class CustomOrgLink < ApplicationRecord + TEXT_MAX_LENGTH = 30 + + belongs_to :casa_org + validates :text, :url, presence: true + validates :text, length: {maximum: TEXT_MAX_LENGTH} + validates :active, inclusion: {in: [true, false]} + validates :url, url: true +end + +# == Schema Information +# +# Table name: custom_org_links +# +# id :bigint not null, primary key +# active :boolean default(TRUE), not null +# text :string not null +# url :string not null +# created_at :datetime not null +# updated_at :datetime not null +# casa_org_id :bigint not null +# +# Indexes +# +# index_custom_org_links_on_casa_org_id (casa_org_id) +# +# Foreign Keys +# +# fk_rails_... (casa_org_id => casa_orgs.id) +# diff --git a/app/policies/custom_org_link_policy.rb b/app/policies/custom_org_link_policy.rb new file mode 100644 index 0000000000..e9395cf72d --- /dev/null +++ b/app/policies/custom_org_link_policy.rb @@ -0,0 +1,12 @@ +class CustomOrgLinkPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + case user + when CasaAdmin + scope.where(casa_org: @user.casa_org) + else + scope.none + end + end + end +end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb new file mode 100644 index 0000000000..c6d5e15089 --- /dev/null +++ b/app/validators/url_validator.rb @@ -0,0 +1,29 @@ +class UrlValidator < ActiveModel::EachValidator + InvalidSchemeError = Class.new StandardError + MissingHostError = Class.new StandardError + + DEFAULT_SCHEMES = %w[http https].freeze + + def validate_each(record, attribute, value) + uri = URI.parse(value) + validate_scheme uri + validate_host uri + rescue URI::InvalidURIError + record.errors.add(attribute, "format is invalid") + rescue InvalidSchemeError, MissingHostError => e + record.errors.add(attribute, e.message) + end + + private + + def validate_scheme(uri) + accepted_schemes = Array.wrap(options[:scheme] || DEFAULT_SCHEMES) + return if uri.scheme.in? accepted_schemes + + raise InvalidSchemeError, "scheme invalid - only #{accepted_schemes.join(", ")} allowed" + end + + def validate_host(uri) + raise MissingHostError, "host cannot be blank" if uri.host.blank? + end +end diff --git a/app/views/casa_org/_custom_org_links.html.erb b/app/views/casa_org/_custom_org_links.html.erb new file mode 100644 index 0000000000..3d73f663e6 --- /dev/null +++ b/app/views/casa_org/_custom_org_links.html.erb @@ -0,0 +1,65 @@ +
+
+
+
+
+

Custom Links

+
+
+ +
+
+
+ + + + + + + + + + + <% if @custom_org_links.blank? %> + + + + <% else %> + <% @custom_org_links.each do |custom_link| %> + + + + + + + <% end %> + <% end %> + +
Display TextURLActive?Actions
+ No custom links have been added for this organization. +
<%= custom_link.text %><%= truncate(custom_link.url, length: 90) %><%= custom_link.active ? "Yes" : "No" %> + <%= link_to edit_custom_org_link_path(custom_link) do %> +
+ +
+ <% end %> + <%= link_to custom_org_link_path(custom_link), method: :delete do %> +
+ +
+ <% end %> +
+
+
+
+
diff --git a/app/views/casa_org/edit.html.erb b/app/views/casa_org/edit.html.erb index d5327109bb..97aab5fa36 100644 --- a/app/views/casa_org/edit.html.erb +++ b/app/views/casa_org/edit.html.erb @@ -99,6 +99,9 @@
<%= render "languages", languages: current_organization.languages %>
+
+ <%= render "custom_org_links" %> +
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 @@ +
+
+
+
+

+ <%= title %> +

+
+
+
+
+ + +
+ <%= 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