diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2783fd451f..dd0403a5af 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,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 @@ -42,27 +43,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] = response.code == 201 || response.code == 200 ? 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 @@ -77,32 +82,32 @@ 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 - "error" - rescue # unverfied error isnt picked up by Twilio::Rest::RestError + twilio_res.error_code.nil? ? 'sent' : 'error' + rescue Twilio::REST::RestError => e + @error = e + 'error' + rescue StandardError # unverfied error isnt picked up by Twilio::Rest::RestError # https://www.twilio.com/docs/errors/21608 - @error = "Phone number is unverifiied" - "error" + @error = 'Phone number is unverifiied' + 'error' end end def sms_acct_creation_notice(resource_name, sms_status) case sms_status - when "blank" + when 'blank' "New #{resource_name} created successfully." - when "error" + when 'error' "New #{resource_name} created successfully. SMS not sent. Error: #{@error}." - when "sent" + when 'sent' "New #{resource_name} created successfully. SMS has been sent!" end 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:) @@ -136,22 +141,18 @@ def set_current_organization def not_authorized session[:user_return_to] = nil - flash[:notice] = "Sorry, you are not authorized to perform this action." + flash[:notice] = 'Sorry, you are not authorized to perform this action.' redirect_to(root_url) 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..741da7297f 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.where(soft_delete: false) + 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..1df8e54091 --- /dev/null +++ b/app/controllers/custom_links_controller.rb @@ -0,0 +1,61 @@ +class CustomLinksController < ApplicationController + before_action :set_custom_link, only: %i[edit update destroy] + + # GET /custom_links/new + def new + authorize CustomLink + custom_link = CustomLink.new(casa_org_id: current_user.casa_org_id) + @custom_link = custom_link + end + + # GET /custom_links/1/edit + def edit + authorize @custom_link + end + + # POST /custom_links + def create + authorize CustomLink + + @custom_link = CustomLink.new(custom_link_params) + + 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 b5b9c7461c..4c7b0158c0 100644 --- a/app/models/casa_org.rb +++ b/app/models/casa_org.rb @@ -26,6 +26,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 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..2358f21137 --- /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..47239921a2 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 4352a14943..800842012e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -139,6 +139,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 e4ba1f6685..7025b05919 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -301,6 +301,17 @@ 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 "soft_delete", default: false, 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 @@ -718,6 +729,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/requests/cutom_links_spec.rb b/spec/requests/cutom_links_spec.rb new file mode 100644 index 0000000000..78a258181f --- /dev/null +++ b/spec/requests/cutom_links_spec.rb @@ -0,0 +1,150 @@ +require 'rails_helper' + +RSpec.describe CustomLinksController, 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 + allow_any_instance_of(CustomLinkPolicy).to receive(:new?).and_return(true) + allow_any_instance_of(CustomLinkPolicy).to receive(:edit?).and_return(true) + allow_any_instance_of(CustomLinkPolicy).to receive(:create?).and_return(true) + allow_any_instance_of(CustomLinkPolicy).to receive(:update?).and_return(true) + allow_any_instance_of(CustomLinkPolicy).to receive(:destroy?).and_return(true) + end + + describe 'GET #new' do + it 'authorizes the action' do + expect_any_instance_of(CustomLinkPolicy).to receive(:new?).and_return(true) + get new_custom_link_path + 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)).to be_a_new(CustomLink) + expect(assigns(:custom_link).casa_org_id).to eq(user.casa_org_id) + end + + it 'renders the new template' do + get new_custom_link_path + expect(response).to render_template(:new) + 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 + expect_any_instance_of(CustomLinkPolicy).to receive(:edit?).and_return(true) + get edit_custom_link_path(custom_link) + 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 + expect_any_instance_of(CustomLinkPolicy).to receive(:create?).and_return(true) + post custom_links_path, params: { custom_link: valid_attributes } + 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.to change(CustomLink, :count).by(0) + 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 + let(: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 + expect_any_instance_of(CustomLinkPolicy).to receive(:update?).and_return(true) + patch custom_link_path(custom_link), params: { custom_link: new_attributes } + 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 + expect_any_instance_of(CustomLinkPolicy).to receive(:destroy?).and_return(true) + delete custom_link_path(custom_link) + end + end +end