Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app/datatables/case_contact_datatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +61 to 70
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new method rather than extending search_filter because the two are fundamentally different in structure. search_filter builds a raw SQL WHERE clause that ORs conditions together against a single free-text string β€” merging AND-logic filter conditions into it would break both features. apply_additional_filters chains named ActiveRecord scopes instead, each adding an ANDed WHERE condition, which is exactly the semantics filters need.


def raw_records
Expand Down
44 changes: 44 additions & 0 deletions app/javascript/src/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!== '' rather than a plain truthy check is intentional. The "Not Reached" option has the value "false", which is a non-empty string but would fail a truthy check, silently dropping the filter. The empty string "" is the only value that means "no filter selected" (the default "Display all" option), so checking against that specifically is a deliberate choice.

if (noDrafts) filters.no_drafts = '1'

return filters
}

$(() => { // JQuery's callback for the DOM loading
Expand Down
98 changes: 96 additions & 2 deletions app/views/case_contacts/case_contacts_new_design/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,11 +1,105 @@
<div class="title-wrapper pt-30">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-30">
<h1>Case Contacts</h1>
<h1 class="mb-20">Case Contacts</h1>

<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3" id="cc-filter-toolbar">
<button type="button" id="cc-filter-toggle" class="main-btn secondary-btn btn-sm btn-hover">
<i class="lni lni-funnel mr-5" aria-hidden="true"></i>
Filter
</button>
Comment on lines +4 to +8
Copy link
Copy Markdown
Collaborator Author

@cliftonmcintosh cliftonmcintosh May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the color of this button be the same as the color of the New Case Contact button? The Figma has it as white with a dark blue icon and dark blue text.

As coded:

Image

In the Figma:

Image

Here's my rationale for the current color:

"New Case Contact" is the primary action on the page. The Filter button is a utility action, used less frequently and by a subset of users. Giving the primary action a filled, prominent style and the secondary action a less prominent style guides the eye to the most important action without hiding the secondary one.

If they were the same colour, the two buttons would compete for attention equally, which would be visually noisy and would undermine the distinction between "the thing you're here to do" and "a tool to help you find things."

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is what it would look like with the dark-btn-outline version. I think this makes it hard to see until hover.

Screen.Recording.2026-05-08.at.13.26.41.mov


<%= link_to new_case_contact_path, class: "main-btn primary-btn btn-sm btn-hover" do %>
<i class="lni lni-plus mr-10" aria-hidden="true"></i>
New Case Contact
<% end %>
</div>

<div id="cc-filter-panel" class="card-style mb-3" style="display: none;">
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline style rather than a CSS class is intentional here. The inline style takes effect immediately as the browser parses the HTML, before any JavaScript loads. A CSS class would leave a window where the panel is briefly visible while the JS bundle is still loading, causing a flash of unwanted content on page load.

<div class="card-content">
<div class="row mb-3">
<div class="col-12"><h5>Date of contact</h5></div>
<div class="col-sm-6 input-style-1">
<label for="cc-filter-occurred-starting-at">Starting from</label>
<input type="date" id="cc-filter-occurred-starting-at" class="form-control" autocomplete="off">
</div>
<div class="col-sm-6 input-style-1">
<label for="cc-filter-occurred-ending-at">Ending at</label>
<input type="date" id="cc-filter-occurred-ending-at" class="form-control" autocomplete="off">
</div>
</div>

<div class="row mb-3">
<div class="col-12"><h5>Case</h5></div>
<% current_organization.casa_cases.order(:case_number).each do |casa_case| %>
<div class="col-md-4">
<div class="form-check">
<input type="checkbox"
class="form-check-input cc-filter-casa-case"
id="cc-filter-case-<%= casa_case.id %>"
value="<%= casa_case.id %>">
<label class="form-check-label" for="cc-filter-case-<%= casa_case.id %>">
<%= casa_case.case_number %>
</label>
</div>
</div>
<% end %>
</div>

<div class="row mb-3">
<div class="col-12"><h5>Relationship</h5></div>
<% @current_organization_groups.each do |group| %>
<div class="col-md-4">
<h6><%= group.name %></h6>
<% group.contact_types.each do |contact_type| %>
<div class="form-check">
<input type="checkbox"
class="form-check-input cc-filter-contact-type"
id="cc-filter-ct-<%= contact_type.id %>"
value="<%= contact_type.id %>">
<label class="form-check-label" for="cc-filter-ct-<%= contact_type.id %>">
<%= contact_type.name %>
</label>
</div>
<% end %>
</div>
<% end %>
</div>

<div class="row mb-3">
<div class="col-md-4 select-style-1">
<label for="cc-filter-medium">Medium</label>
<div class="select-position">
<select id="cc-filter-medium" class="form-select">
<option value="">Display all</option>
<% CaseContact::CONTACT_MEDIUMS.each do |medium| %>
<option value="<%= medium %>"><%= medium.titleize %></option>
<% end %>
</select>
</div>
</div>
<div class="col-md-4 select-style-1">
<label for="cc-filter-contact-made">Contacted</label>
<div class="select-position">
<select id="cc-filter-contact-made" class="form-select">
<option value="">Display all</option>
<option value="true">Reached</option>
<option value="false">Not Reached</option>
</select>
</div>
</div>
<div class="col-md-4 d-flex align-items-end pb-2">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="cc-filter-no-drafts">
<label class="form-check-label" for="cc-filter-no-drafts">Hide drafts</label>
</div>
</div>
</div>

<div class="d-flex gap-2">
<button type="button" id="cc-filter-apply" class="main-btn primary-btn btn-sm btn-hover">Apply Filters</button>
<button type="button" id="cc-filter-reset" class="main-btn dark-btn-outline btn-sm btn-hover">Reset</button>
</div>
</div>
</div>
</div>

<div class="card-style mb-30">
Expand Down
117 changes: 117 additions & 0 deletions spec/requests/case_contacts/case_contacts_new_design_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
Loading
Loading