From 030b87f003b1d08b978777402d2b14149e4e505b Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 8 May 2026 10:24:10 -0500 Subject: [PATCH 1/3] Add server-side filtering to the case contacts datatable Co-Authored-By: Claude Sonnet 4.6 --- app/datatables/case_contact_datatable.rb | 13 +- .../case_contacts_new_design_spec.rb | 117 ++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb index 4f5b28a8ff..7240caf1a9 100644 --- a/app/datatables/case_contact_datatable.rb +++ b/app/datatables/case_contact_datatable.rb @@ -55,7 +55,18 @@ def data end def filtered_records - raw_records.where(search_filter) + apply_additional_filters(raw_records.where(search_filter)) + end + + def apply_additional_filters(records) + records = records.occurred_starting_at(additional_filters[:occurred_starting_at]) + records = records.occurred_ending_at(additional_filters[:occurred_ending_at]) + records = records.with_casa_case(Array(additional_filters[:casa_case_ids])) if additional_filters[:casa_case_ids].present? + records = records.contact_type(Array(additional_filters[:contact_type_ids])) if additional_filters[:contact_type_ids].present? + records = records.contact_medium(additional_filters[:contact_medium]) + records = records.contact_made(additional_filters[:contact_made]) + records = records.no_drafts(additional_filters[:no_drafts].to_i) if additional_filters[:no_drafts].present? + records end def raw_records 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 6f4348ce78..be3543d1e8 100644 --- a/spec/requests/case_contacts/case_contacts_new_design_spec.rb +++ b/spec/requests/case_contacts/case_contacts_new_design_spec.rb @@ -116,6 +116,123 @@ end end + context "with additional_filters" do + let!(:other_case) { create(:casa_case, casa_org: organization) } + let(:contact_type) { create(:contact_type) } + + def post_with_filters(filters) + post datatable_case_contacts_new_design_path, + params: datatable_params.merge(additional_filters: filters), + as: :json + end + + def ids + JSON.parse(response.body, symbolize_names: true)[:data].pluck(:id) + end + + it "filters by occurred_starting_at" do + old_contact = create(:case_contact, :active, casa_case: casa_case, occurred_at: 2.weeks.ago) + + post_with_filters(occurred_starting_at: 1.week.ago.to_date.to_s) + + expect(ids).to include(case_contact.id.to_s) + expect(ids).not_to include(old_contact.id.to_s) + end + + it "filters by occurred_ending_at" do + old_contact = create(:case_contact, :active, casa_case: casa_case, occurred_at: 2.weeks.ago) + recent_contact = create(:case_contact, :active, casa_case: casa_case, occurred_at: Time.zone.today) + + post_with_filters(occurred_ending_at: 1.week.ago.to_date.to_s) + + expect(ids).to include(old_contact.id.to_s) + expect(ids).not_to include(recent_contact.id.to_s) + end + + it "filters by casa_case_ids" do + other_contact = create(:case_contact, :active, casa_case: other_case) + + post_with_filters(casa_case_ids: [casa_case.id.to_s]) + + expect(ids).to include(case_contact.id.to_s) + expect(ids).not_to include(other_contact.id.to_s) + end + + it "filters by contact_type_ids" do + matching_contact = create(:case_contact, :active, casa_case: casa_case, contact_types: [contact_type]) + non_matching_contact = create(:case_contact, :active, casa_case: casa_case, contact_types: [create(:contact_type)]) + + post_with_filters(contact_type_ids: [contact_type.id.to_s]) + + expect(ids).to include(matching_contact.id.to_s) + expect(ids).not_to include(non_matching_contact.id.to_s) + end + + it "filters by contact_medium" do + in_person_contact = create(:case_contact, :active, casa_case: casa_case, medium_type: CaseContact::IN_PERSON) + video_contact = create(:case_contact, :active, casa_case: casa_case, medium_type: CaseContact::VIDEO) + + post_with_filters(contact_medium: CaseContact::IN_PERSON) + + expect(ids).to include(in_person_contact.id.to_s) + expect(ids).not_to include(video_contact.id.to_s) + end + + it "filters by contact_made true" do + reached_contact = create(:case_contact, :active, casa_case: casa_case, contact_made: true) + not_reached_contact = create(:case_contact, :active, casa_case: casa_case, contact_made: false) + + post_with_filters(contact_made: "true") + + expect(ids).to include(reached_contact.id.to_s) + expect(ids).not_to include(not_reached_contact.id.to_s) + end + + it "filters by contact_made false" do + reached_contact = create(:case_contact, :active, casa_case: casa_case, contact_made: true) + not_reached_contact = create(:case_contact, :active, casa_case: casa_case, contact_made: false) + + post_with_filters(contact_made: "false") + + expect(ids).to include(not_reached_contact.id.to_s) + expect(ids).not_to include(reached_contact.id.to_s) + end + + it "filters by no_drafts" do + active_contact = create(:case_contact, :active, casa_case: casa_case) + draft_contact = create(:case_contact, casa_case: casa_case, status: "started") + + post_with_filters(no_drafts: "1") + + expect(ids).to include(active_contact.id.to_s) + expect(ids).not_to include(draft_contact.id.to_s) + end + + it "combines multiple filters with AND logic" do + matching = create(:case_contact, :active, + casa_case: casa_case, + medium_type: CaseContact::IN_PERSON, + occurred_at: 2.days.ago) + wrong_medium = create(:case_contact, :active, + casa_case: casa_case, + medium_type: CaseContact::VIDEO, + occurred_at: 2.days.ago) + wrong_case = create(:case_contact, :active, + casa_case: other_case, + medium_type: CaseContact::IN_PERSON, + occurred_at: 2.days.ago) + + post_with_filters( + casa_case_ids: [casa_case.id.to_s], + contact_medium: CaseContact::IN_PERSON + ) + + expect(ids).to include(matching.id.to_s) + expect(ids).not_to include(wrong_medium.id.to_s) + expect(ids).not_to include(wrong_case.id.to_s) + end + end + context "when user is a volunteer" do let(:volunteer) { create(:volunteer, casa_org: organization) } From 520a54ab7fff0d74cb92b68c4728420255ac0f54 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 8 May 2026 10:51:35 -0500 Subject: [PATCH 2/3] Add filter panel UI and JS to case contacts new design Adds the filter toolbar (Filter button + New Case Contact), a collapsible filter panel (date range, case, relationship, medium, contacted, hide drafts), and the JS to collect and POST additional_filters on apply/reset. Includes system specs. Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/src/dashboard.js | 44 +++++++++ .../case_contacts_new_design/index.html.erb | 98 ++++++++++++++++++- .../case_contacts_new_design_spec.rb | 88 +++++++++++++++++ 3 files changed, 228 insertions(+), 2 deletions(-) diff --git a/app/javascript/src/dashboard.js b/app/javascript/src/dashboard.js index af0932ed6e..d15f36986a 100644 --- a/app/javascript/src/dashboard.js +++ b/app/javascript/src/dashboard.js @@ -44,6 +44,13 @@ const defineCaseContactsTable = function () { ajax: { url: $('table#case_contacts').data('source'), type: 'POST', + data: function (d) { + const filters = collectCaseContactFilters() + if (Object.keys(filters).length > 0) { + d.additional_filters = filters + } + return d + }, error: function (xhr, error, code) { console.error('DataTable error:', error, code) }, @@ -248,6 +255,43 @@ const defineCaseContactsTable = function () { success: () => table.ajax.reload(null, false) }) }) + + $('#cc-filter-toggle').on('click', function () { + $('#cc-filter-panel').toggle() + }) + + $('#cc-filter-apply').on('click', function () { + table.ajax.reload(null, false) + $('#cc-filter-panel').hide() + }) + + $('#cc-filter-reset').on('click', function () { + $('#cc-filter-occurred-starting-at, #cc-filter-occurred-ending-at').val('') + $('#cc-filter-medium, #cc-filter-contact-made').val('') + $('.cc-filter-casa-case, .cc-filter-contact-type, #cc-filter-no-drafts').prop('checked', false) + table.ajax.reload(null, false) + }) +} + +function collectCaseContactFilters () { + const filters = {} + const startDate = $('#cc-filter-occurred-starting-at').val() + const endDate = $('#cc-filter-occurred-ending-at').val() + const casaIds = $('.cc-filter-casa-case:checked').map((_, el) => el.value).get() + const contactTypeIds = $('.cc-filter-contact-type:checked').map((_, el) => el.value).get() + const medium = $('#cc-filter-medium').val() + const contactMade = $('#cc-filter-contact-made').val() + const noDrafts = $('#cc-filter-no-drafts').is(':checked') + + if (startDate) filters.occurred_starting_at = startDate + if (endDate) filters.occurred_ending_at = endDate + if (casaIds.length > 0) filters.casa_case_ids = casaIds + if (contactTypeIds.length > 0) filters.contact_type_ids = contactTypeIds + if (medium) filters.contact_medium = medium + if (contactMade !== '') filters.contact_made = contactMade + if (noDrafts) filters.no_drafts = '1' + + return filters } $(() => { // JQuery's callback for the DOM loading diff --git a/app/views/case_contacts/case_contacts_new_design/index.html.erb b/app/views/case_contacts/case_contacts_new_design/index.html.erb index e2873841ac..98b0f410e5 100644 --- a/app/views/case_contacts/case_contacts_new_design/index.html.erb +++ b/app/views/case_contacts/case_contacts_new_design/index.html.erb @@ -1,11 +1,105 @@
-
-

Case Contacts

+

Case Contacts

+ +
+ + <%= link_to new_case_contact_path, class: "main-btn primary-btn btn-sm btn-hover" do %> New Case Contact <% end %>
+ +
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 afbae54c5a..158263a4ae 100644 --- a/spec/system/case_contacts/case_contacts_new_design_spec.rb +++ b/spec/system/case_contacts/case_contacts_new_design_spec.rb @@ -25,6 +25,94 @@ end end + describe "filter panel" do + let!(:in_person_contact) do + create(:case_contact, :active, casa_case: casa_case, + medium_type: CaseContact::IN_PERSON, occurred_at: 5.days.ago) + end + let!(:video_contact) do + create(:case_contact, :active, casa_case: casa_case, + medium_type: CaseContact::VIDEO, occurred_at: 2.days.ago) + end + let!(:draft_contact) do + create(:case_contact, casa_case: casa_case, status: "started", occurred_at: 1.day.ago) + end + + before do + sign_in admin + visit case_contacts_new_design_path + end + + it "shows the Filter button" do + expect(page).to have_button("Filter") + end + + it "hides the filter panel by default" do + expect(page).not_to have_css("#cc-filter-panel", visible: true) + end + + it "opens the filter panel when the Filter button is clicked" do + click_button "Filter" + expect(page).to have_css("#cc-filter-panel", visible: true) + end + + it "closes the filter panel when the Filter button is clicked again" do + click_button "Filter" + click_button "Filter" + expect(page).not_to have_css("#cc-filter-panel", visible: true) + end + + it "filters by contact medium" do + in_person_date = I18n.l(in_person_contact.occurred_at, format: :full) + video_date = I18n.l(video_contact.occurred_at, format: :full) + + click_button "Filter" + select "In Person", from: "cc-filter-medium" + click_button "Apply Filters" + + expect(page).to have_text(in_person_date) + expect(page).not_to have_text(video_date) + end + + it "filters by date range" do + old_date = I18n.l(in_person_contact.occurred_at, format: :full) + recent_date = I18n.l(video_contact.occurred_at, format: :full) + + click_button "Filter" + execute_script("document.getElementById('cc-filter-occurred-ending-at').value = '#{4.days.ago.to_date}'") + click_button "Apply Filters" + + expect(page).to have_text(old_date) + expect(page).not_to have_text(recent_date) + end + + it "hides drafts when the hide drafts checkbox is checked" do + draft_date = I18n.l(draft_contact.occurred_at, format: :full) + + click_button "Filter" + check "cc-filter-no-drafts" + click_button "Apply Filters" + + expect(page).not_to have_text(draft_date) + end + + it "resets all filters when the Reset button is clicked" do + in_person_date = I18n.l(in_person_contact.occurred_at, format: :full) + video_date = I18n.l(video_contact.occurred_at, format: :full) + + click_button "Filter" + select "In Person", from: "cc-filter-medium" + click_button "Apply Filters" + expect(page).not_to have_text(video_date) + + click_button "Filter" + click_button "Reset" + + expect(page).to have_text(in_person_date) + expect(page).to have_text(video_date) + end + end + describe "New Case Contact button" do include_context "signed in as admin" From 516878deae812f06a26593ba6df205710ffcb6e3 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 8 May 2026 11:24:39 -0500 Subject: [PATCH 3/3] Add autocomplete="off" to date filter inputs to fix erb_lint warnings Co-Authored-By: Claude Sonnet 4.6 --- .../case_contacts/case_contacts_new_design/index.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/case_contacts/case_contacts_new_design/index.html.erb b/app/views/case_contacts/case_contacts_new_design/index.html.erb index 98b0f410e5..48f7076004 100644 --- a/app/views/case_contacts/case_contacts_new_design/index.html.erb +++ b/app/views/case_contacts/case_contacts_new_design/index.html.erb @@ -19,11 +19,11 @@
Date of contact
- +
- +