+ `
+ }
}
]
})
@@ -167,6 +205,49 @@ const defineCaseContactsTable = function () {
$(this).addClass('expanded').attr('aria-expanded', 'true')
}
})
+
+ const csrfToken = () => $('meta[name="csrf-token"]').attr('content')
+
+ $('table#case_contacts').on('click', '.cc-delete-action', async function () {
+ const id = $(this).data('id')
+ const { isConfirmed } = await Swal.fire({
+ title: 'Delete this contact?',
+ showCancelButton: true,
+ confirmButtonText: 'Delete',
+ confirmButtonColor: '#dc3545'
+ })
+ if (!isConfirmed) return
+ $.ajax({
+ url: `/case_contacts/${id}`,
+ type: 'DELETE',
+ headers: { 'X-CSRF-Token': csrfToken(), Accept: 'application/json' },
+ success: () => table.ajax.reload(null, false)
+ })
+ })
+
+ $('table#case_contacts').on('click', '.cc-set-reminder-action', async function () {
+ const id = $(this).data('id')
+ const { value: text, isConfirmed } = await fireSwalFollowupAlert()
+ if (!isConfirmed) return
+ const params = text ? { note: text } : {}
+ $.ajax({
+ url: `/case_contacts/${id}/followups`,
+ type: 'POST',
+ data: params,
+ headers: { 'X-CSRF-Token': csrfToken(), Accept: 'application/json' },
+ success: () => table.ajax.reload(null, false)
+ })
+ })
+
+ $('table#case_contacts').on('click', '.cc-resolve-reminder-action', function () {
+ const followupId = $(this).data('followup-id')
+ $.ajax({
+ url: `/followups/${followupId}/resolve`,
+ type: 'PATCH',
+ headers: { 'X-CSRF-Token': csrfToken(), Accept: 'application/json' },
+ success: () => table.ajax.reload(null, false)
+ })
+ })
}
$(() => { // JQuery's callback for the DOM loading
diff --git a/spec/datatables/case_contact_datatable_spec.rb b/spec/datatables/case_contact_datatable_spec.rb
index a68ec02be8..8a943c81f0 100644
--- a/spec/datatables/case_contact_datatable_spec.rb
+++ b/spec/datatables/case_contact_datatable_spec.rb
@@ -30,7 +30,9 @@
let(:order_direction) { "desc" }
let(:base_relation) { organization.case_contacts }
- subject(:datatable) { described_class.new(base_relation, params) }
+ let(:current_user) { create(:casa_admin, casa_org: organization) }
+
+ subject(:datatable) { described_class.new(base_relation, params, current_user) }
describe "#data" do
let!(:case_contact) do
@@ -105,6 +107,71 @@
expect(contact_data[:is_draft]).to eq((!case_contact.active?).to_s)
end
+ context "with action metadata" do
+ it "includes all expected action metadata keys" do
+ contact_data = datatable.as_json[:data].first
+ expect(contact_data).to include(:can_edit, :can_destroy, :edit_path, :followup_id, :has_followup)
+ end
+
+ it "includes edit_path for the contact" do
+ contact_data = datatable.as_json[:data].first
+ expected_path = Rails.application.routes.url_helpers.edit_case_contact_path(case_contact)
+ expect(contact_data[:edit_path]).to eq(expected_path)
+ end
+
+ it "includes followup_id as empty string when no requested followup exists" do
+ contact_data = datatable.as_json[:data].first
+ expect(contact_data[:followup_id]).to eq("")
+ end
+
+ it "includes followup_id when a requested followup exists" do
+ followup = create(:followup, case_contact: case_contact, status: "requested")
+ contact_data = datatable.as_json[:data].first
+ expect(contact_data[:followup_id]).to eq(followup.id.to_s)
+ end
+
+ it "does not include followup_id for a resolved followup" do
+ create(:followup, case_contact: case_contact, status: "resolved")
+ contact_data = datatable.as_json[:data].first
+ expect(contact_data[:followup_id]).to eq("")
+ end
+ end
+
+ context "with permission flags" do
+ context "when current_user is an admin" do
+ it "sets can_edit to true" do
+ expect(datatable.as_json[:data].first[:can_edit]).to eq("true")
+ end
+
+ it "sets can_destroy to true" do
+ expect(datatable.as_json[:data].first[:can_destroy]).to eq("true")
+ end
+ end
+
+ context "when current_user is the volunteer who created the contact" do
+ let(:current_user) { volunteer }
+
+ it "sets can_edit to true" do
+ expect(datatable.as_json[:data].first[:can_edit]).to eq("true")
+ end
+
+ it "sets can_destroy to false for an active contact" do
+ expect(datatable.as_json[:data].first[:can_destroy]).to eq("false")
+ end
+ end
+
+ context "when current_user is the volunteer who created a draft contact" do
+ let(:current_user) { volunteer }
+ let!(:case_contact) do
+ create(:case_contact, casa_case: casa_case, creator: volunteer, status: "started")
+ end
+
+ it "sets can_destroy to true for their own draft" do
+ expect(datatable.as_json[:data].first[:can_destroy]).to eq("true")
+ end
+ end
+ end
+
context "when case_contact has no casa_case (draft)" do
let!(:draft_contact) do
build(:case_contact,
@@ -414,6 +481,32 @@
end
end
+ describe "N+1 queries" do
+ let!(:contacts) do
+ 3.times.map do
+ create(:case_contact,
+ casa_case: create(:casa_case, casa_org: organization),
+ creator: create(:volunteer, casa_org: organization))
+ end
+ end
+
+ it "does not trigger N+1 queries for casa_org when computing per-row policy permissions" do
+ casa_org_queries = []
+ subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
+ casa_org_queries << payload[:sql] if payload[:sql].match?(/SELECT.*casa_orgs/)
+ end
+
+ datatable.as_json
+
+ ActiveSupport::Notifications.unsubscribe(subscription)
+
+ # With proper eager loading, casa_orgs is fetched in at most 2 batch queries
+ # (one for :casa_org through casa_case, one for :creator_casa_org through creator).
+ # An N+1 would fire 1 query per record (3+ here).
+ expect(casa_org_queries.length).to be <= 2
+ end
+ end
+
describe "associations loading" do
let!(:contacts) do
10.times.map do |i|
diff --git a/spec/requests/case_contacts/case_contacts_new_design_spec.rb b/spec/requests/case_contacts/case_contacts_new_design_spec.rb
index 16f085cc2d..6f4348ce78 100644
--- a/spec/requests/case_contacts/case_contacts_new_design_spec.rb
+++ b/spec/requests/case_contacts/case_contacts_new_design_spec.rb
@@ -264,6 +264,68 @@
expect(record[:contact_topics]).to include(contact_topic.question)
end
end
+
+ context "with permission flags and action metadata" do
+ let(:volunteer) { create(:volunteer, casa_org: organization) }
+ let!(:active_contact) { create(:case_contact, :active, casa_case: casa_case, creator: volunteer) }
+ let!(:draft_contact) { create(:case_contact, casa_case: casa_case, creator: volunteer, status: "started") }
+
+ def post_datatable
+ post datatable_case_contacts_new_design_path, params: datatable_params, as: :json
+ end
+
+ def row_for(contact_id)
+ json = JSON.parse(response.body, symbolize_names: true)
+ json[:data].find { |row| row[:id] == contact_id.to_s }
+ end
+
+ context "when signed in as admin" do
+ it "includes can_edit as true" do
+ post_datatable
+ expect(row_for(active_contact.id)[:can_edit]).to eq("true")
+ end
+
+ it "includes can_destroy as true" do
+ post_datatable
+ expect(row_for(active_contact.id)[:can_destroy]).to eq("true")
+ end
+
+ it "includes edit_path for the contact" do
+ post_datatable
+ expect(row_for(active_contact.id)[:edit_path]).to eq(edit_case_contact_path(active_contact))
+ end
+
+ it "includes followup_id as empty when no followup exists" do
+ post_datatable
+ expect(row_for(active_contact.id)[:followup_id]).to eq("")
+ end
+
+ it "includes followup_id when a requested followup exists" do
+ followup = create(:followup, case_contact: active_contact, status: :requested, creator: admin)
+ post_datatable
+ expect(row_for(active_contact.id)[:followup_id]).to eq(followup.id.to_s)
+ end
+ end
+
+ context "when signed in as volunteer" do
+ before { sign_in volunteer }
+
+ it "includes can_edit as true for their own contact" do
+ post_datatable
+ expect(row_for(active_contact.id)[:can_edit]).to eq("true")
+ end
+
+ it "includes can_destroy as false for their own active contact" do
+ post_datatable
+ expect(row_for(active_contact.id)[:can_destroy]).to eq("false")
+ end
+
+ it "includes can_destroy as true for their own draft contact" do
+ post_datatable
+ expect(row_for(draft_contact.id)[:can_destroy]).to eq("true")
+ end
+ end
+ end
end
end
end
diff --git a/spec/requests/case_contacts/followups_spec.rb b/spec/requests/case_contacts/followups_spec.rb
index 49a6b486b7..6bf0594ced 100644
--- a/spec/requests/case_contacts/followups_spec.rb
+++ b/spec/requests/case_contacts/followups_spec.rb
@@ -27,6 +27,21 @@
expect(followup.note).to eq "Hello, world!"
end
+ context "when requested as JSON" do
+ subject(:request) do
+ post case_contact_followups_path(case_contact),
+ params: params,
+ headers: {"Accept" => "application/json"}
+
+ response
+ end
+
+ it "returns 204 No Content" do
+ request
+ expect(response).to have_http_status(:no_content)
+ end
+ end
+
it "sends a Followup Notifier to case contact creator" do
request
followup = Followup.last
@@ -66,6 +81,21 @@
expect { request }.to change { followup.reload.resolved? }.from(false).to(true)
end
+ context "when requested as JSON" do
+ subject(:request) do
+ patch resolve_followup_path(followup),
+ headers: {"Accept" => "application/json"}
+
+ response
+ end
+
+ it "returns 204 No Content" do
+ followup
+ request
+ expect(response).to have_http_status(:no_content)
+ end
+ end
+
it "does not send Followup Notifier" do
followup
expect(FollowupResolvedNotifier).not_to receive(:with)
diff --git a/spec/system/case_contacts/case_contacts_new_design_spec.rb b/spec/system/case_contacts/case_contacts_new_design_spec.rb
index 3e05c44cd4..af2364a531 100644
--- a/spec/system/case_contacts/case_contacts_new_design_spec.rb
+++ b/spec/system/case_contacts/case_contacts_new_design_spec.rb
@@ -1,6 +1,6 @@
require "rails_helper"
-RSpec.describe "Case Contact Table Row Expansion", type: :system, js: true do
+RSpec.describe "Case contacts new design", type: :system, js: true do
let(:organization) { create(:casa_org) }
let(:admin) { create(:casa_admin, casa_org: organization) }
let(:casa_case) { create(:casa_case, casa_org: organization) }
@@ -14,30 +14,196 @@
case_contact: case_contact,
contact_topic: contact_topic,
value: "Youth is doing well in school")
+ allow(Flipper).to receive(:enabled?).and_call_original
allow(Flipper).to receive(:enabled?).with(:new_case_contact_table).and_return(true)
- sign_in admin
- visit case_contacts_new_design_path
end
- it "shows the expanded content after clicking the chevron" do
- find(".expand-toggle").click
+ shared_context "signed in as admin" do
+ before do
+ sign_in admin
+ visit case_contacts_new_design_path
+ end
+ end
+
+ describe "row expansion" do
+ include_context "signed in as admin"
+ it "shows the expanded content after clicking the chevron" do
+ find(".expand-toggle").click
+
+ expect(page).to have_content("What was discussed?")
+ expect(page).to have_content("Youth is doing well in school")
+ end
+
+ it "shows notes in the expanded content" do
+ find(".expand-toggle").click
+
+ expect(page).to have_content("Additional Notes")
+ expect(page).to have_content("Important follow-up needed")
+ end
+
+ it "hides the expanded content after clicking the chevron again" do
+ find(".expand-toggle").click
+ expect(page).to have_content("Youth is doing well in school")
+
+ find(".expand-toggle").click
+ expect(page).to have_no_content("Youth is doing well in school")
+ end
+ end
+
+ describe "action menu" do
+ include_context "signed in as admin"
+ it "opens the dropdown when the ellipsis button is clicked" do
+ find(".cc-ellipsis-toggle").click
+
+ expect(page).to have_css(".dropdown-menu.show")
+ end
+
+ it "shows Edit in the menu" do
+ find(".cc-ellipsis-toggle").click
+
+ expect(page).to have_text("Edit")
+ end
+
+ it "shows Delete in the menu" do
+ find(".cc-ellipsis-toggle").click
+
+ expect(page).to have_text("Delete")
+ end
+
+ it "shows Set Reminder when no followup exists" do
+ find(".cc-ellipsis-toggle").click
+
+ expect(page).to have_text("Set Reminder")
+ expect(page).to have_no_text("Resolve Reminder")
+ end
+
+ it "shows Resolve Reminder when a requested followup exists" do
+ create(:followup, case_contact: case_contact, status: :requested, creator: admin)
+ visit case_contacts_new_design_path
+
+ find(".cc-ellipsis-toggle").click
+
+ expect(page).to have_text("Resolve Reminder")
+ expect(page).to have_no_text("Set Reminder")
+ end
+
+ it "closes the dropdown when clicking outside" do
+ find(".cc-ellipsis-toggle").click
+ expect(page).to have_css(".dropdown-menu.show")
+
+ find("h1").click
+ expect(page).to have_no_css(".dropdown-menu.show")
+ end
+ end
+
+ describe "Edit action" do
+ include_context "signed in as admin"
+ it "navigates to the edit form when Edit is clicked" do
+ find(".cc-ellipsis-toggle").click
+ click_link "Edit"
- expect(page).to have_content("What was discussed?")
- expect(page).to have_content("Youth is doing well in school")
+ expect(page).to have_current_path(/case_contacts\/#{case_contact.id}\/form/)
+ end
end
- it "shows notes in the expanded content" do
- find(".expand-toggle").click
+ describe "Delete action" do
+ include_context "signed in as admin"
+ let(:occurred_at_text) { I18n.l(case_contact.occurred_at, format: :full) }
- expect(page).to have_content("Additional Notes")
- expect(page).to have_content("Important follow-up needed")
+ it "removes the row after confirming the delete dialog" do
+ expect(page).to have_text(occurred_at_text)
+
+ find(".cc-ellipsis-toggle").click
+ find(".cc-delete-action").click
+ click_button "Delete"
+
+ expect(page).to have_no_text(occurred_at_text)
+ end
+
+ it "leaves the row in place when the delete dialog is cancelled" do
+ expect(page).to have_text(occurred_at_text)
+
+ find(".cc-ellipsis-toggle").click
+ find(".cc-delete-action").click
+ click_button "Cancel"
+
+ expect(page).to have_text(occurred_at_text)
+ end
+ end
+
+ describe "Set Reminder action" do
+ include_context "signed in as admin"
+ it "creates a followup and shows Resolve Reminder in the menu after confirming" do
+ find(".cc-ellipsis-toggle").click
+ find(".cc-set-reminder-action").click
+ click_button "Confirm"
+
+ expect(page).to have_css("i.fas.fa-bell:not([style])")
+
+ find(".cc-ellipsis-toggle").click
+ expect(page).to have_text("Resolve Reminder")
+ expect(page).to have_no_text("Set Reminder")
+ end
+
+ it "does not create a followup when cancelled" do
+ find(".cc-ellipsis-toggle").click
+ find(".cc-set-reminder-action").click
+ click_button "Cancel"
+
+ expect(case_contact.followups.reload).to be_empty
+ end
end
- it "hides the expanded content after clicking the chevron again" do
- find(".expand-toggle").click
- expect(page).to have_content("Youth is doing well in school")
+ describe "Resolve Reminder action" do
+ include_context "signed in as admin"
+
+ let!(:followup) { create(:followup, case_contact: case_contact, status: :requested, creator: admin) }
+
+ before { visit case_contacts_new_design_path }
+
+ it "resolves the followup and shows Set Reminder in the menu afterwards" do
+ find(".cc-ellipsis-toggle").click
+ find(".cc-resolve-reminder-action").click
+
+ expect(page).to have_css("i.fas.fa-bell[style*='opacity']")
+
+ find(".cc-ellipsis-toggle").click
+ expect(page).to have_text("Set Reminder")
+ expect(page).to have_no_text("Resolve Reminder")
+ end
+
+ it "marks the followup as resolved" do
+ find(".cc-ellipsis-toggle").click
+ find(".cc-resolve-reminder-action").click
+
+ # Wait for reload to confirm the AJAX completed before checking DB
+ expect(page).to have_css("i.fas.fa-bell[style*='opacity']")
+
+ expect(followup.reload.status).to eq("resolved")
+ end
+ end
+
+ describe "permission states" do
+ let(:volunteer) { create(:volunteer, casa_org: organization) }
+ let(:casa_case_for_volunteer) { create(:casa_case, casa_org: organization) }
+ let!(:active_contact) { create(:case_contact, :active, casa_case: casa_case_for_volunteer, creator: volunteer, occurred_at: 5.days.ago) }
+ let!(:draft_contact) { create(:case_contact, casa_case: casa_case_for_volunteer, creator: volunteer, status: "started", occurred_at: 10.days.ago) }
+
+ before do
+ sign_in volunteer
+ visit case_contacts_new_design_path
+ end
+
+ it "shows Delete as disabled for an active contact" do
+ find("#cc-actions-btn-#{active_contact.id}").click
+ expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{active_contact.id}'].show")
+ expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{active_contact.id}'] button.dropdown-item.disabled", text: "Delete")
+ end
- find(".expand-toggle").click
- expect(page).to have_no_content("Youth is doing well in school")
+ it "shows Delete as enabled for a draft contact" do
+ find("#cc-actions-btn-#{draft_contact.id}").click
+ expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{draft_contact.id}'].show")
+ expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{draft_contact.id}'] button.cc-delete-action", text: "Delete")
+ end
end
end