diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 024dd07985..378e1b66b8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base before_action :set_timeout_duration before_action :set_current_organization before_action :set_active_banner + before_action :set_custom_links after_action :verify_authorized, except: :index, unless: :devise_controller? # after_action :verify_policy_scoped, only: :index @@ -45,27 +46,31 @@ def set_active_banner @active_banner = nil if @active_banner&.expired? end + def set_custom_links + return unless current_organization + + @custom_links = current_organization.custom_links.active + end + protected def handle_short_url(url_list) hash_of_short_urls = {} - url_list.each_with_index { |val, index| + url_list.each_with_index do |val, index| # call short io service to shorten url # create an entry in hash if api is success short_io_service = ShortUrlService.new response = short_io_service.create_short_url(val) short_url = short_io_service.short_url - hash_of_short_urls[index] = (response.code == 201 || response.code == 200) ? short_url : nil - } + hash_of_short_urls[index] = [201, 200].include?(response.code) ? short_url : nil + end hash_of_short_urls end # volunteer/supervisor/casa_admin controller uses to send SMS # returns appropriate flash notice for SMS def deliver_sms_to(resource, body_msg) - if resource.phone_number.blank? || !resource.casa_org.twilio_enabled? - return "blank" - end + return "blank" if resource.phone_number.blank? || !resource.casa_org.twilio_enabled? body = body_msg to = resource.phone_number @@ -81,8 +86,8 @@ def deliver_sms_to(resource, body_msg) begin twilio_res = @twilio.send_sms(req_params) twilio_res.error_code.nil? ? "sent" : "error" - rescue Twilio::REST::RestError => error - @error = error + rescue Twilio::REST::RestError => e + @error = e "error" rescue # unverfied error isnt picked up by Twilio::Rest::RestError # https://www.twilio.com/docs/errors/21608 @@ -103,9 +108,9 @@ def sms_acct_creation_notice(resource_name, sms_status) end def store_referring_location - if request.referer && !request.referer.end_with?("users/sign_in") && params[:ignore_referer].blank? - session[:return_to] = request.referer - end + return unless request.referer && !request.referer.end_with?("users/sign_in") && params[:ignore_referer].blank? + + session[:return_to] = request.referer end def redirect_back_to_referer(fallback_location:) @@ -170,17 +175,13 @@ def unsupported_media_type end def log_and_reraise(error) - unless KNOWN_ERRORS.include?(error.class) - Bugsnag.notify(error) - end + Bugsnag.notify(error) unless KNOWN_ERRORS.include?(error.class) raise end def check_unconfirmed_email_notice(user) notice = "#{user.role} was successfully updated." - if user.saved_changes.include?("unconfirmed_email") - notice += " Confirmation Email Sent." - end + notice += " Confirmation Email Sent." if user.saved_changes.include?("unconfirmed_email") notice end end diff --git a/app/controllers/casa_org_controller.rb b/app/controllers/casa_org_controller.rb index 16bc3d7701..1b980aa5c5 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_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_links + @custom_links = @casa_org.custom_links + end + def set_active_storage_url_options ActiveStorage::Current.url_options = {host: request.base_url} end diff --git a/app/controllers/custom_links_controller.rb b/app/controllers/custom_links_controller.rb new file mode 100644 index 0000000000..ce8baa6961 --- /dev/null +++ b/app/controllers/custom_links_controller.rb @@ -0,0 +1,59 @@ +class CustomLinksController < ApplicationController + before_action :set_custom_link, only: %i[edit update destroy] + + # GET /custom_links/new + def new + @custom_link = CustomLink.new(casa_org_id: current_user.casa_org_id) + authorize @customLink + end + + # GET /custom_links/1/edit + def edit + authorize @custom_link + end + + # POST /custom_links + def create + @custom_link = CustomLink.new(custom_link_params) + authorize @custom_link + + if @custom_link.save + redirect_to edit_casa_org_path(current_organization), notice: "Custom link was successfully created." + else + render :new + end + end + + # PATCH/PUT /custom_links/1 + def update + authorize @custom_link + if @custom_link.update(custom_link_params) + redirect_to edit_casa_org_path(current_organization), notice: "Custom link was successfully updated." + else + render :edit + end + end + + # DELETE /custom_links/1/delete + def destroy + authorize @custom_link + + if @custom_link.destroy + redirect_to edit_casa_org_path(current_organization), notice: "Custom link was successfully removed." + else + render :show, status: :unprocessable_entity + end + end + + private + + # Use callbacks to share common setup or constraints between actions. + def set_custom_link + @custom_link = CustomLink.find(params[:id]) + end + + # Only allow a list of trusted parameters through. + def custom_link_params + params.require(:custom_link).permit(:text, :url, :active, :casa_org_id) + end +end diff --git a/app/models/casa_org.rb b/app/models/casa_org.rb index 5c98594cda..7b3220ad72 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_links has_one_attached :logo has_one_attached :court_report_template has_many :placement_types, dependent: :destroy diff --git a/app/models/custom_link.rb b/app/models/custom_link.rb new file mode 100644 index 0000000000..8f4959e6c4 --- /dev/null +++ b/app/models/custom_link.rb @@ -0,0 +1,31 @@ +class CustomLink < ApplicationRecord + belongs_to :casa_org + # Validate that the URL is present, has a valid format, and is unique + validates :url, presence: true, + format: {with: /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/, message: "must be a valid URL"} + # Validate that the title is present and has a maximum length of 255 characters + validates :text, presence: true, length: {maximum: 255} + + scope :active, -> { where(active: true) } +end + +# == Schema Information +# +# Table name: custom_links +# +# id :bigint not null, primary key +# active :boolean default(TRUE), not null +# text :string +# url :text +# created_at :datetime not null +# updated_at :datetime not null +# casa_org_id :bigint not null +# +# Indexes +# +# index_custom_links_on_casa_org_id (casa_org_id) +# +# Foreign Keys +# +# fk_rails_... (casa_org_id => casa_orgs.id) +# diff --git a/app/policies/custom_link_policy.rb b/app/policies/custom_link_policy.rb new file mode 100644 index 0000000000..4de5cb29d8 --- /dev/null +++ b/app/policies/custom_link_policy.rb @@ -0,0 +1,8 @@ +class CustomLinkPolicy < ApplicationPolicy + alias_method :create?, :is_admin_same_org? + alias_method :edit?, :is_admin_same_org? + alias_method :new?, :is_admin_same_org? + alias_method :show?, :is_admin_same_org? + alias_method :update?, :is_admin_same_org? + alias_method :destroy?, :is_admin_same_org? +end diff --git a/app/views/casa_org/_custom_links.html.erb b/app/views/casa_org/_custom_links.html.erb new file mode 100644 index 0000000000..53d18ec0cb --- /dev/null +++ b/app/views/casa_org/_custom_links.html.erb @@ -0,0 +1,64 @@ +
+
+
+
+
+

Custom Link

+
+
+ +
+
+
+ + + + + + + + + + <% @custom_links&.each do |custom_link| %> + <% id = "custom_link-#{custom_link.id}" %> + + + + + + + <%= render(Modal::GroupComponent.new(id: id)) do |component| %> + <% component.with_header(text: "Delete Custom Link?", id: id) %> + <% component.with_body(text: [ + "This custom link will be deleted and will no longer be visible in profile dropdown."]) %> + <% component.with_footer do %> + <%= link_to custom_link_path(custom_link), method: :delete, + class: "btn-sm main-btn danger-btn btn-hover ms-auto" do %> + + Delete Custom Link + <% end %> + <% end %> + <% end %> + <% end %> + +
Display TextURLActive?
+ <%= custom_link.text %> + <%= custom_link.url %> + <%= custom_link.active ? "Yes" : "No" %> + + <%= render(DropdownMenuComponent.new(menu_title: "Actions Menu", hide_label: true)) do %> +
  • <%= link_to "Edit", edit_custom_link_path(custom_link), class: "dropdown-item" %>
  • +
  • <%= render(Modal::OpenLinkComponent.new(text: "Delete", target: id, klass: "dropdown-item")) %>
  • + <% end %> +
    +
    +
    +
    +
    diff --git a/app/views/casa_org/edit.html.erb b/app/views/casa_org/edit.html.erb index d5327109bb..74b607030e 100644 --- a/app/views/casa_org/edit.html.erb +++ b/app/views/casa_org/edit.html.erb @@ -163,3 +163,17 @@
    <%= render "contact_topics" %>
    +
    +
    +
    +
    +

    + Manage Custom Links +

    +
    +
    +
    +
    +
    + <%= render "custom_links" %> +
    diff --git a/app/views/custom_links/_form.html.erb b/app/views/custom_links/_form.html.erb new file mode 100644 index 0000000000..9011c5d159 --- /dev/null +++ b/app/views/custom_links/_form.html.erb @@ -0,0 +1,39 @@ +
    +
    +
    +
    +

    + <%= title %> +

    +
    +
    +
    +
    + + +
    + <%= form_with(model: custom_link, local: true) do |form| %> + <%= form.hidden_field :casa_org_id %> +
    + <%= render "/shared/error_messages", resource: custom_link %> +
    +
    + <%= form.label :text, "Display Text" %> + <%= form.text_field :text, class: "form-control", required: true %> +
    +
    + <%= form.label :url, "URL" %> + <%= form.text_field :url, rows: 5, class: "form-control", required: true %> +
    +
    + <%= form.check_box :active, 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 %> + Submit + <% end %> +
    + <% end %> +
    + diff --git a/app/views/custom_links/edit.html.erb b/app/views/custom_links/edit.html.erb new file mode 100644 index 0000000000..dd75347084 --- /dev/null +++ b/app/views/custom_links/edit.html.erb @@ -0,0 +1 @@ +<%= render partial: "form", locals: {title: "Custom Link", custom_link: @custom_link} %> diff --git a/app/views/custom_links/new.html.erb b/app/views/custom_links/new.html.erb new file mode 100644 index 0000000000..d225077815 --- /dev/null +++ b/app/views/custom_links/new.html.erb @@ -0,0 +1 @@ +<%= render partial: "form", locals: {title: "New Custom Link", custom_link: @custom_link} %> diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 75ccef2185..d0c7a863d9 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -72,6 +72,13 @@ <%= current_user.email %> + <% @custom_links&.each do |custom_link| %> +
  • + <%= link_to custom_link.url, target: "_blank" do %> + <%= custom_link.text %> + <% end %> +
  • + <% end %>
  • <%= link_to edit_users_path do %> diff --git a/config/routes.rb b/config/routes.rb index 7344e21169..af2013cbc6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -147,6 +147,9 @@ delete "soft_delete", on: :member end + resources :custom_links, except: %i[index show] do + end + resources :followup_reports, only: :index resources :placement_reports, only: :index resources :banners, except: %i[show] do diff --git a/db/migrate/20240513155246_create_custom_links.rb b/db/migrate/20240513155246_create_custom_links.rb new file mode 100644 index 0000000000..86a10fd772 --- /dev/null +++ b/db/migrate/20240513155246_create_custom_links.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Migration file to create custom_links table +class CreateCustomLinks < ActiveRecord::Migration[7.1] + def change + create_table :custom_links do |t| + t.string :text + t.text :url + t.references :casa_org, null: false, foreign_key: true + t.boolean :active, null: false, default: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7e2fe6d13c..bd8ac1e876 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -315,6 +315,16 @@ t.index ["judge_id"], name: "index_court_dates_on_judge_id" end + create_table "custom_links", force: :cascade do |t| + t.string "text" + t.text "url" + t.bigint "casa_org_id", 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_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 @@ -732,6 +742,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_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_links.rb b/spec/factories/custom_links.rb new file mode 100644 index 0000000000..de892f0a6a --- /dev/null +++ b/spec/factories/custom_links.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :custom_link do + text { "Example Link" } + url { "http://example.com" } + association :casa_org + end +end diff --git a/spec/models/custom_link_spec.rb b/spec/models/custom_link_spec.rb new file mode 100644 index 0000000000..73f9974535 --- /dev/null +++ b/spec/models/custom_link_spec.rb @@ -0,0 +1,5 @@ +require "rails_helper" + +RSpec.describe CustomLink, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/policies/custom_link_policy_spec.rb b/spec/policies/custom_link_policy_spec.rb new file mode 100644 index 0000000000..dd6509c6b9 --- /dev/null +++ b/spec/policies/custom_link_policy_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe CustomLinkPolicy do + subject(:organization) { create(:casa_org) } + + let(:volunteer) { create(:volunteer, :with_single_case, casa_org: organization) } + let(:supervisor) { create(:supervisor, casa_org: organization) } + let(:casa_admin) { create(:casa_admin, casa_org: organization) } + let(:custom_link) { create(:custom_link, casa_org: organization) } + let(:other_org_admin) { create(:casa_admin) } + + permissions :create?, :edit?, :new?, :show?, :update? do + it "allows same org casa_admins" do + expect(described_class).to permit(casa_admin, custom_link) + end + + it "does not allow different org casa_admins" do + expect(described_class).not_to permit(other_org_admin, custom_link) + end + + it "does not permit supervisor" do + expect(described_class).not_to permit(supervisor, custom_link) + end + + it "does not permit volunteer" do + expect(described_class).not_to permit(volunteer, custom_link) + end + end +end diff --git a/spec/requests/custom_links_spec.rb b/spec/requests/custom_links_spec.rb new file mode 100644 index 0000000000..e2aa2b0997 --- /dev/null +++ b/spec/requests/custom_links_spec.rb @@ -0,0 +1,168 @@ +require "rails_helper" + +RSpec.describe "/casa_cases/:casa_case_id/custom_links/", type: :request do + let(:user) { create(:user) } + let(:admin) { create(:casa_admin, casa_org: user.casa_org) } + let(:custom_link) { create(:custom_link, casa_org_id: user.casa_org_id) } + let(:valid_attributes) { {text: "Link Text", url: "http://example.com", active: true, casa_org_id: user.casa_org_id} } + let(:invalid_attributes) { {text: "", url: "invalid", active: nil} } + + before do + sign_in user + custom_link_policy = instance_double(CustomLinkPolicy) + allow(custom_link_policy).to receive_messages( + new?: true, + edit?: true, + create?: true, + update?: true, + destroy?: true + ) + + allow(CustomLinkPolicy).to receive(:new).and_return(custom_link_policy) + end + + describe "GET #new" do + it "authorizes the action" do + custom_link_policy = instance_double(CustomLinkPolicy) + + allow(CustomLinkPolicy).to receive(:new).and_return(custom_link_policy) + allow(custom_link_policy).to receive(:new?).and_return(true) + + get new_custom_link_path + expect(response).to have_http_status(:found) + end + + it "assigns a new CustomLink with the current user's casa_org_id to @custom_link" do + get new_custom_link_path + expect(assigns(:custom_link).casa_org_id).to eq(user.casa_org_id) + end + end + + describe "GET #edit" do + it "assigns the requested custom_link as @custom_link" do + get edit_custom_link_path(custom_link) + expect(assigns(:custom_link)).to eq(custom_link) + end + + it "authorizes the action" do + custom_link_policy = instance_double(CustomLinkPolicy) + allow(CustomLinkPolicy).to receive(:new).and_return(custom_link_policy) + allow(custom_link_policy).to receive(:edit?).and_return(true) + get edit_custom_link_path(custom_link) + expect(custom_link_policy).to have_received(:edit?) + end + end + + describe "POST #create" do + context "with valid parameters" do + it "creates a new CustomLink" do + expect do + post custom_links_path, params: {custom_link: valid_attributes} + end.to change(CustomLink, :count).by(1) + end + + it "redirects to the edit_casa_org_path" do + post custom_links_path, params: {custom_link: valid_attributes} + expect(response).to redirect_to(edit_casa_org_path(user.casa_org)) + end + + it "sets a success notice" do + post custom_links_path, params: {custom_link: valid_attributes} + expect(flash[:notice]).to eq("Custom link was successfully created.") + end + + it "authorizes the action" do + custom_link_policy = instance_double(CustomLinkPolicy) + + allow(CustomLinkPolicy).to receive(:new).and_return(custom_link_policy) + allow(custom_link_policy).to receive(:create?).and_return(true) + + post custom_links_path, params: {custom_link: valid_attributes} + expect(response).to have_http_status(:found) + end + end + + context "with invalid parameters" do + it "does not create a new CustomLink" do + expect do + post custom_links_path, params: {custom_link: invalid_attributes} + end.not_to change(CustomLink, :count) + end + + it "renders the new template" do + post custom_links_path, params: {custom_link: invalid_attributes} + expect(response).to render_template(:new) + end + end + end + + describe "PATCH/PUT #update" do + context "with valid parameters" do + subject(:new_attributes) { {text: "Updated Text", url: "http://updated.com"} } + + it "updates the requested custom_link" do + patch custom_link_path(custom_link), params: {custom_link: new_attributes} + custom_link.reload + expect(custom_link.text).to eq("Updated Text") + end + + it "redirects to the edit_casa_org_path" do + patch custom_link_path(custom_link), params: {custom_link: new_attributes} + expect(response).to redirect_to(edit_casa_org_path(user.casa_org)) + end + + it "sets a success notice" do + patch custom_link_path(custom_link), params: {custom_link: new_attributes} + expect(flash[:notice]).to eq("Custom link was successfully updated.") + end + + it "authorizes the action" do + custom_link_policy = instance_double(CustomLinkPolicy) + allow(CustomLinkPolicy).to receive(:new).and_return(custom_link_policy) + allow(custom_link_policy).to receive(:update?).and_return(true) + patch custom_link_path(custom_link), params: {custom_link: new_attributes} + expect(custom_link_policy).to have_received(:update?) + end + end + + context "with invalid parameters" do + it "does not update the requested custom_link" do + patch custom_link_path(custom_link), params: {custom_link: invalid_attributes} + custom_link.reload + expect(custom_link.text).not_to be_empty + end + + it "renders the edit template" do + patch custom_link_path(custom_link), params: {custom_link: invalid_attributes} + expect(response).to render_template(:edit) + end + end + end + + describe "DELETE #destroy" do + it "destroys the requested custom_link" do + custom_link + expect do + delete custom_link_path(custom_link) + end.to change(CustomLink, :count).by(-1) + end + + it "redirects to the edit_casa_org_path" do + delete custom_link_path(custom_link) + expect(response).to redirect_to(edit_casa_org_path(user.casa_org)) + end + + it "sets a success notice" do + delete custom_link_path(custom_link) + expect(flash[:notice]).to eq("Custom link was successfully removed.") + end + + it "authorizes the action" do + custom_link_policy = instance_double(CustomLinkPolicy) + allow(CustomLinkPolicy).to receive(:new).and_return(custom_link_policy) + allow(custom_link_policy).to receive(:destroy?).and_return(true) + delete custom_link_path(custom_link) + expect(custom_link_policy).to have_received(:destroy?) + end + end +end