feat(#6502): Filter for new case contact table#6942
feat(#6502): Filter for new case contact table#6942cliftonmcintosh wants to merge 3 commits intorubyforgood:mainfrom
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| 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 |
There was a problem hiding this comment.
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.
| <% end %> | ||
| </div> | ||
|
|
||
| <div id="cc-filter-panel" class="card-style mb-3" style="display: none;"> |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
!== '' 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.
| 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}'") |
There was a problem hiding this comment.
execute_script is used here instead of Capybara's fill_in or find.set because those can be unreliable for type="date" inputs in headless Chrome — the browser's native date picker intercepts the interaction and jQuery does not see the resulting value. Setting the value directly on the DOM element via JavaScript bypasses the picker and gives jQuery a value it can read with .val() when the filter is applied.
| <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> |
There was a problem hiding this comment.
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:
In the Figma:
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."
There was a problem hiding this comment.
Here is what it would look like with the dark-btn-outline version. I think this makes it hard to see until hover.
What github issue is this PR for, if any?
Resolves #6502
What changed, and why?
Adds a filter button and collapsible filter panel to the new case contacts table (
/case_contacts/new_design).Backend
app/datatables/case_contact_datatable.rbAdded a new
apply_additional_filtersmethod that pipes the datatable query through existingCaseContactmodel scopes before returning results. Each scope already handles nil/blank values gracefully, so only active filters affect the query. Filters combine with AND logic — a record must satisfy every active filter simultaneously.Filters supported:
Frontend
app/views/case_contacts/case_contacts_new_design/index.html.erbapp/javascript/src/dashboard.jsRestructured the page header into a filter toolbar row with the Filter button on the left and New Case Contact on the right.
The filter values are collected by a
collectCaseContactFilters()function and attached to every DataTables POST request via theajax.datahook, so filters remain in effect when the user sorts or pages through results.How is this tested? (please write rspec and jest tests!) 💖💪
Request specs (
spec/requests/case_contacts/case_contacts_new_design_spec.rb)Nine tests covering each filter param individually and in combination:
occurred_starting_at— returns only contacts on or after the dateoccurred_ending_at— returns only contacts on or before the datecasa_case_ids— returns only contacts for the specified casescontact_type_ids— returns only contacts with the specified contact typescontact_medium— returns only contacts with the specified mediumcontact_made: true— returns only contacts where contact was madecontact_made: false— returns only contacts where contact was not madeno_drafts— excludes draft contactsSystem specs (
spec/system/case_contacts/case_contacts_new_design_spec.rb)Seven tests covering the filter UI:
Screen recordings
Opening and closing the filter panel
The Filter button lives in the toolbar row to the left of the New Case Contact button. Clicking it toggles a collapsible panel containing all filter inputs. The panel is hidden by default and does not affect the table until a filter is applied.
Screen.Recording.2026-05-08.at.11.15.50.mov
Applying a single filter
Selecting a value and clicking Apply Filters triggers a DataTables reload with the filter attached to the server request. Only rows matching the filter are returned; the table updates in place without a full page refresh.
Screen.Recording.2026-05-08.at.11.16.39.mov
Combining multiple filters and resetting
All active filters are applied together with AND logic — a contact must satisfy every active filter to appear in the results. Clicking Reset clears all inputs and reloads the table with no filters applied, restoring the full result set.
Screen.Recording.2026-05-08.at.11.20.59.mov
Mobile view
The filter toolbar and panel are built with Bootstrap's responsive grid. At phone-sized widths:
Screen.Recording.2026-05-08.at.11.13.41.mov
Differences from the Figma design
The Figma mockup informed this implementation but was not followed exactly. Questions were raised with the designer via this Slack thread but went unanswered. A fellow developer advised:
The deviations are intentional and documented below.
Flat panel instead of drill-down overlay
The Figma shows a two-level drill-down overlay: clicking Filter opens a category list, and clicking a category slides into a sub-panel with that filter's options. A single flat panel was implemented instead, showing all filters at once. The flat panel is simpler to build, simpler to test, and easier to reason about. The backend
additional_filtersparam contract is identical regardless of UI shape, so replacing the flat panel with the drill-down overlay in a future ticket is a pure frontend change with no backend work required.Medium, Contacted, and Draft — single-select instead of multi-select
The Figma shows these three as checkbox-based filters supporting multiple simultaneous selections. All three were implemented as single-select dropdowns instead. The existing model scopes for these filters accept a single value; supporting multi-select would require updating the scopes and the frontend. The single-select versions deliver useful functionality now, and the upgrade path is straightforward when prioritised.
Created By and Topics filters omitted
The Figma includes Created By and Topics filters. Both were skipped. Created By has an existing backend scope but raises an open question about which users to list in the UI. Topics has no backend scope at all. Both are self-contained additions that can be done in follow-up tickets without changing anything built here.
Want Driving Reimbursement filter dropped
The old design included a Want Driving Reimbursement filter. The Figma mockup for the new design does not include it, so it was omitted.
Feelings gif (optional)