diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb
new file mode 100644
index 0000000000..bfeb930877
--- /dev/null
+++ b/app/datatables/case_contact_datatable.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+class CaseContactDatatable < ApplicationDatatable
+ ORDERABLE_FIELDS = %w[
+ occurred_at
+ contact_made
+ medium_type
+ duration_minutes
+ ].freeze
+
+ private
+
+ def data
+ records.map do |case_contact|
+ {
+ id: case_contact.id,
+ occurred_at: I18n.l(case_contact.occurred_at, format: :full, default: nil),
+ casa_case: {
+ id: case_contact.casa_case_id,
+ case_number: case_contact.casa_case&.case_number
+ },
+ contact_types: case_contact.contact_types.map(&:name).join(", "),
+ medium_type: case_contact.medium_type&.titleize,
+ creator: {
+ id: case_contact.creator_id,
+ display_name: case_contact.creator&.display_name,
+ email: case_contact.creator&.email,
+ role: case_contact.creator&.role
+ },
+ contact_made: case_contact.contact_made,
+ duration_minutes: case_contact.duration_minutes,
+ contact_topics: case_contact.contact_topics.map(&:question).join(" | "),
+ is_draft: !case_contact.active?,
+ has_followup: case_contact.followups.requested.exists?
+ }
+ end
+ end
+
+ def filtered_records
+ raw_records.where(search_filter)
+ end
+
+ def raw_records
+ base_relation
+ .joins("INNER JOIN users creators ON creators.id = case_contacts.creator_id")
+ .left_joins(:casa_case)
+ .includes(:contact_types, :contact_topics, :followups, :creator)
+ .order(order_clause)
+ .order(:id)
+ end
+
+ def search_filter
+ return "TRUE" if search_term.blank?
+
+ ilike_fields = %w[
+ creators.display_name
+ creators.email
+ casa_cases.case_number
+ case_contacts.notes
+ ]
+
+ ilike_clauses = ilike_fields.map { |field| "#{field} ILIKE ?" }.join(" OR ")
+ contact_type_clause = "case_contacts.id IN (#{contact_type_search_subquery})"
+
+ full_clause = "#{ilike_clauses} OR #{contact_type_clause}"
+ [full_clause, ilike_fields.count.times.map { "%#{search_term}%" }].flatten
+ end
+
+ def contact_type_search_subquery
+ @contact_type_search_subquery ||= lambda {
+ return "SELECT NULL WHERE FALSE" if search_term.blank?
+
+ CaseContact
+ .select("DISTINCT case_contacts.id")
+ .joins(case_contact_contact_types: :contact_type)
+ .where("contact_types.name ILIKE ?", "%#{search_term}%")
+ .to_sql
+ }.call
+ end
+
+ def order_clause
+ @order_clause ||= build_order_clause
+ end
+end
diff --git a/app/javascript/__tests__/dashboard.test.js b/app/javascript/__tests__/dashboard.test.js
new file mode 100644
index 0000000000..3f5a33e0d9
--- /dev/null
+++ b/app/javascript/__tests__/dashboard.test.js
@@ -0,0 +1,413 @@
+/* eslint-env jest */
+/**
+ * @jest-environment jsdom
+ */
+
+import { defineCaseContactsTable } from '../src/dashboard'
+
+// Mock DataTable
+const mockDataTable = jest.fn()
+$.fn.DataTable = mockDataTable
+
+describe('defineCaseContactsTable', () => {
+ let tableElement
+
+ beforeEach(() => {
+ // Reset mocks
+ mockDataTable.mockClear()
+
+ // Set up DOM
+ document.body.innerHTML = `
+
+ `
+
+ tableElement = $('table#case_contacts')
+ })
+
+ describe('DataTable initialization', () => {
+ it('initializes DataTable on the case_contacts table', () => {
+ defineCaseContactsTable()
+
+ expect(mockDataTable).toHaveBeenCalledTimes(1)
+ expect(mockDataTable.mock.instances[0][0]).toBe(tableElement[0])
+ })
+
+ it('configures DataTable with server-side processing', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+
+ expect(config.serverSide).toBe(true)
+ expect(config.processing).toBe(true)
+ expect(config.searching).toBe(true)
+ })
+
+ it('configures scrollX for horizontal scrolling', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+
+ expect(config.scrollX).toBe(true)
+ })
+
+ it('sets default sort to Date column descending', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+
+ expect(config.order).toEqual([[2, 'desc']])
+ })
+
+ it('disables ordering on bell, chevron, and ellipsis columns', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+
+ expect(config.columnDefs).toEqual([
+ { orderable: false, targets: [0, 1, 10] }
+ ])
+ })
+ })
+
+ describe('AJAX configuration', () => {
+ it('uses the data-source URL from the table', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+
+ expect(config.ajax.url).toBe('/case_contacts/new_design/datatable.json')
+ expect(config.ajax.type).toBe('POST')
+ expect(config.ajax.dataType).toBe('json')
+ })
+
+ it('includes error handler for AJAX requests', () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
+
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+ const mockError = 'Network error'
+ const mockCode = 500
+
+ config.ajax.error({}, mockError, mockCode)
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('DataTable error:', mockError, mockCode)
+
+ consoleErrorSpy.mockRestore()
+ })
+ })
+
+ describe('column configurations', () => {
+ let columns
+
+ beforeEach(() => {
+ defineCaseContactsTable()
+ columns = mockDataTable.mock.calls[0][0].columns
+ })
+
+ it('configures 11 columns', () => {
+ expect(columns).toHaveLength(11)
+ })
+
+ describe('Bell icon column (index 0)', () => {
+ it('is not orderable or searchable', () => {
+ expect(columns[0].orderable).toBe(false)
+ expect(columns[0].searchable).toBe(false)
+ })
+
+ it('renders filled bell icon when has_followup is "true"', () => {
+ const rendered = columns[0].render('true', 'display', {})
+
+ expect(rendered).toBe('')
+ })
+
+ it('renders faded bell icon when has_followup is "false"', () => {
+ const rendered = columns[0].render('false', 'display', {})
+
+ expect(rendered).toBe('')
+ })
+ })
+
+ describe('Chevron icon column (index 1)', () => {
+ it('is not orderable or searchable', () => {
+ expect(columns[1].orderable).toBe(false)
+ expect(columns[1].searchable).toBe(false)
+ })
+
+ it('renders chevron-down icon', () => {
+ const rendered = columns[1].render(null, 'display', {})
+
+ expect(rendered).toBe('')
+ })
+ })
+
+ describe('Date column (index 2)', () => {
+ it('uses occurred_at data field', () => {
+ expect(columns[2].data).toBe('occurred_at')
+ expect(columns[2].name).toBe('occurred_at')
+ })
+
+ it('renders date string or empty string', () => {
+ expect(columns[2].render('January 15, 2024')).toBe('January 15, 2024')
+ expect(columns[2].render(null)).toBe('')
+ expect(columns[2].render('')).toBe('')
+ })
+ })
+
+ describe('Case column (index 3)', () => {
+ it('is not orderable', () => {
+ expect(columns[3].orderable).toBe(false)
+ })
+
+ it('renders link to casa_case when data exists', () => {
+ const data = { id: '123', case_number: 'CASA-2024-001' }
+ const rendered = columns[3].render(data, 'display', {})
+
+ expect(rendered).toBe('CASA-2024-001')
+ })
+
+ it('renders empty string when casa_case is null', () => {
+ expect(columns[3].render(null, 'display', {})).toBe('')
+ })
+
+ it('renders empty string when casa_case has no id', () => {
+ const data = { id: null, case_number: 'CASA-2024-001' }
+
+ expect(columns[3].render(data, 'display', {})).toBe('')
+ })
+ })
+
+ describe('Relationship (Contact Types) column (index 4)', () => {
+ it('is not orderable', () => {
+ expect(columns[4].orderable).toBe(false)
+ })
+
+ it('renders contact types string', () => {
+ expect(columns[4].render('Family, School')).toBe('Family, School')
+ expect(columns[4].render(null)).toBe('')
+ })
+ })
+
+ describe('Medium column (index 5)', () => {
+ it('renders medium type', () => {
+ expect(columns[5].render('In-person')).toBe('In-person')
+ expect(columns[5].render('Text/Email')).toBe('Text/Email')
+ expect(columns[5].render(null)).toBe('')
+ })
+ })
+
+ describe('Created By column (index 6)', () => {
+ it('is not orderable', () => {
+ expect(columns[6].orderable).toBe(false)
+ })
+
+ it('renders empty string when creator is null', () => {
+ expect(columns[6].render(null, 'display', {})).toBe('')
+ })
+
+ it('renders link to volunteer edit page for volunteers', () => {
+ const data = {
+ id: '456',
+ display_name: 'John Doe',
+ role: 'Volunteer'
+ }
+ const rendered = columns[6].render(data, 'display', {})
+
+ expect(rendered).toBe('John Doe')
+ })
+
+ it('renders link to supervisor edit page for supervisors', () => {
+ const data = {
+ id: '789',
+ display_name: 'Jane Smith',
+ role: 'Supervisor'
+ }
+ const rendered = columns[6].render(data, 'display', {})
+
+ expect(rendered).toBe('Jane Smith')
+ })
+
+ it('renders link to users edit page for casa admins', () => {
+ const data = {
+ id: '999',
+ display_name: 'Admin User',
+ role: 'Casa Admin'
+ }
+ const rendered = columns[6].render(data, 'display', {})
+
+ expect(rendered).toBe('Admin User')
+ })
+ })
+
+ describe('Contacted column (index 7)', () => {
+ it('is not orderable', () => {
+ expect(columns[7].orderable).toBe(false)
+ })
+
+ it('renders checkmark icon when contact was made', () => {
+ const row = { contact_made: 'true', duration_minutes: null }
+ const rendered = columns[7].render('true', 'display', row)
+
+ expect(rendered).toContain('')
+ })
+
+ it('renders cross icon when contact was not made', () => {
+ const row = { contact_made: 'false', duration_minutes: null }
+ const rendered = columns[7].render('false', 'display', row)
+
+ expect(rendered).toContain('')
+ })
+
+ it('includes formatted duration when present', () => {
+ const row = { contact_made: 'true', duration_minutes: 90 }
+ const rendered = columns[7].render('true', 'display', row)
+
+ expect(rendered).toContain('(01:30)')
+ })
+
+ it('formats duration with leading zeros', () => {
+ const row = { contact_made: 'true', duration_minutes: 5 }
+ const rendered = columns[7].render('true', 'display', row)
+
+ expect(rendered).toContain('(00:05)')
+ })
+
+ it('handles hours and minutes correctly', () => {
+ const row = { contact_made: 'true', duration_minutes: 125 }
+ const rendered = columns[7].render('true', 'display', row)
+
+ expect(rendered).toContain('(02:05)')
+ })
+
+ it('does not include duration when not present', () => {
+ const row = { contact_made: 'true', duration_minutes: null }
+ const rendered = columns[7].render('true', 'display', row)
+
+ expect(rendered).not.toContain('(')
+ })
+ })
+
+ describe('Topics column (index 8)', () => {
+ it('is not orderable', () => {
+ expect(columns[8].orderable).toBe(false)
+ })
+
+ it('renders contact topics string', () => {
+ expect(columns[8].render('Topic 1 | Topic 2')).toBe('Topic 1 | Topic 2')
+ expect(columns[8].render(null)).toBe('')
+ })
+ })
+
+ describe('Draft column (index 9)', () => {
+ it('is not orderable', () => {
+ expect(columns[9].orderable).toBe(false)
+ })
+
+ it('renders Draft badge when is_draft is true', () => {
+ const rendered = columns[9].render(true, 'display', {})
+
+ expect(rendered).toBe('Draft')
+ })
+
+ it('renders empty string when is_draft is false', () => {
+ const rendered = columns[9].render(false, 'display', {})
+
+ expect(rendered).toBe('')
+ })
+
+ it('handles string "true" as truthy', () => {
+ const rendered = columns[9].render('true', 'display', {})
+
+ expect(rendered).toBe('Draft')
+ })
+
+ it('handles string "false" as falsy (explicit check for "true")', () => {
+ const rendered = columns[9].render('false', 'display', {})
+
+ // With explicit check for === true || === "true", string "false" should not render badge
+ expect(rendered).toBe('')
+ })
+
+ it('handles empty string as falsy', () => {
+ const rendered = columns[9].render('', 'display', {})
+
+ expect(rendered).toBe('')
+ })
+ })
+
+ describe('Ellipsis menu column (index 10)', () => {
+ it('is not orderable or searchable', () => {
+ expect(columns[10].orderable).toBe(false)
+ expect(columns[10].searchable).toBe(false)
+ })
+
+ it('renders ellipsis icon', () => {
+ const rendered = columns[10].render(null, 'display', {})
+
+ expect(rendered).toBe('')
+ })
+ })
+ })
+
+ describe('edge cases', () => {
+ it('handles missing data-source attribute gracefully', () => {
+ tableElement.removeAttr('data-source')
+
+ expect(() => defineCaseContactsTable()).not.toThrow()
+
+ const config = mockDataTable.mock.calls[0][0]
+ expect(config.ajax.url).toBeUndefined()
+ })
+
+ it('handles table element not existing', () => {
+ document.body.innerHTML = ''
+
+ // Should not throw when table doesn't exist
+ expect(() => defineCaseContactsTable()).not.toThrow()
+ })
+ })
+
+ describe('DataTable integration', () => {
+ it('passes all required configuration options', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+
+ // Verify all critical config options are present
+ expect(config).toHaveProperty('scrollX')
+ expect(config).toHaveProperty('searching')
+ expect(config).toHaveProperty('processing')
+ expect(config).toHaveProperty('serverSide')
+ expect(config).toHaveProperty('order')
+ expect(config).toHaveProperty('ajax')
+ expect(config).toHaveProperty('columnDefs')
+ expect(config).toHaveProperty('columns')
+ })
+
+ it('configures columns array matching table structure', () => {
+ defineCaseContactsTable()
+
+ const config = mockDataTable.mock.calls[0][0]
+ const headerColumns = $('table#case_contacts thead th').length
+
+ expect(config.columns.length).toBe(headerColumns)
+ })
+ })
+})
diff --git a/app/javascript/src/dashboard.js b/app/javascript/src/dashboard.js
index c6cf66815f..7db962db98 100644
--- a/app/javascript/src/dashboard.js
+++ b/app/javascript/src/dashboard.js
@@ -4,17 +4,127 @@ const { Notifier } = require('./notifier')
let pageNotifier
const defineCaseContactsTable = function () {
- $('table#case_contacts').DataTable(
- {
- scrollX: true,
- searching: false,
- order: [[2, 'desc']],
- columnDefs: [
- { type: 'date', targets: 2 },
- { orderable: false, targets: [0, 1, -1] } // disable sort on bell, chevron, vertical elipses menu
- ]
- }
- )
+ $('table#case_contacts').DataTable({
+ scrollX: true,
+ searching: true,
+ processing: true,
+ serverSide: true,
+ order: [[2, 'desc']], // Sort by Date column (index 2, after bell and chevron)
+ ajax: {
+ url: $('table#case_contacts').data('source'),
+ type: 'POST',
+ error: function (xhr, error, code) {
+ console.error('DataTable error:', error, code)
+ },
+ dataType: 'json'
+ },
+ columnDefs: [
+ { orderable: false, targets: [0, 1, 10] } // Bell, Chevron, and Ellipsis columns not orderable
+ ],
+ columns: [
+ { // Bell icon column (index 0)
+ data: 'has_followup',
+ orderable: false,
+ searchable: false,
+ render: (data, type, row) => {
+ return data === 'true'
+ ? ''
+ : ''
+ }
+ },
+ { // Chevron icon column (index 1)
+ data: null,
+ orderable: false,
+ searchable: false,
+ render: () => ''
+ },
+ { // Date column (index 2)
+ data: 'occurred_at',
+ name: 'occurred_at',
+ render: (data) => data || ''
+ },
+ { // Case column (index 3)
+ data: 'casa_case',
+ orderable: false,
+ render: (data) => {
+ if (!data || !data.id) return ''
+ const a = document.createElement('a')
+ a.href = `/casa_cases/${data.id}`
+ a.textContent = data.case_number
+ return a.outerHTML
+ }
+ },
+ { // Relationship (Contact Types) column (index 4)
+ data: 'contact_types',
+ orderable: false,
+ render: (data) => data || ''
+ },
+ { // Medium column (index 5)
+ data: 'medium_type',
+ render: (data) => data || ''
+ },
+ { // Created By column (index 6)
+ data: 'creator',
+ orderable: false,
+ render: (data) => {
+ if (!data) return ''
+
+ // Build edit link based on role
+ let editPath = ''
+ if (data.role === 'Supervisor') {
+ editPath = `/supervisors/${data.id}/edit`
+ } else if (data.role === 'Casa Admin') {
+ editPath = '/users/edit'
+ } else {
+ editPath = `/volunteers/${data.id}/edit`
+ }
+
+ return $('')
+ .attr('href', editPath)
+ .attr('data-turbo', 'false')
+ .text(data.display_name)
+ .prop('outerHTML')
+ }
+ },
+ { // Contacted column (index 7)
+ data: 'contact_made',
+ orderable: false,
+ render: (data, type, row) => {
+ const icon = data === 'true'
+ ? ''
+ : ''
+
+ let duration = ''
+ if (row.duration_minutes) {
+ const hours = Math.floor(row.duration_minutes / 60)
+ const minutes = row.duration_minutes % 60
+ duration = ` (${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')})`
+ }
+ return icon + duration
+ }
+ },
+ { // Topics column (index 8)
+ data: 'contact_topics',
+ orderable: false,
+ render: (data) => data || ''
+ },
+ { // Draft column (index 9)
+ data: 'is_draft',
+ orderable: false,
+ render: (data) => {
+ return (data === true || data === 'true')
+ ? 'Draft'
+ : ''
+ }
+ },
+ { // Ellipsis menu column (index 10)
+ data: null,
+ orderable: false,
+ searchable: false,
+ render: () => ''
+ }
+ ]
+ })
}
$(() => { // JQuery's callback for the DOM loading
diff --git a/app/policies/case_contact_policy.rb b/app/policies/case_contact_policy.rb
index 9763c82080..491d7c6e58 100644
--- a/app/policies/case_contact_policy.rb
+++ b/app/policies/case_contact_policy.rb
@@ -21,6 +21,7 @@ def additional_expenses_allowed?
end
alias_method :index?, :admin_or_supervisor_or_volunteer?
+ alias_method :datatable?, :admin_or_supervisor_or_volunteer?
alias_method :drafts?, :admin_or_supervisor?
alias_method :edit?, :update?
alias_method :restore?, :is_admin_same_org?
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 826a58173b..82509394fc 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
@@ -11,7 +11,7 @@