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 = ` + + + + + + + + + + + + + + + + + +
DateCaseRelationshipMediumCreated ByContactedTopicsDraft
+ ` + + 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 @@ + data-source="<%= datatable_case_contacts_new_design_path format: :json %>"> diff --git a/config/routes.rb b/config/routes.rb index 6f849a7f53..ecb042d919 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,7 @@ # Feature flag for new case contact table design get "case_contacts/new_design", to: "case_contacts/case_contacts_new_design#index" + post "case_contacts/new_design/datatable", to: "case_contacts/case_contacts_new_design#datatable", as: "datatable_case_contacts_new_design" resources :case_contacts, except: %i[create update show], concerns: %i[with_datatable] do member do post :restore diff --git a/spec/datatables/case_contact_datatable_spec.rb b/spec/datatables/case_contact_datatable_spec.rb new file mode 100644 index 0000000000..970ed186c3 --- /dev/null +++ b/spec/datatables/case_contact_datatable_spec.rb @@ -0,0 +1,447 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CaseContactDatatable do + let(:organization) { create(:casa_org) } + let(:supervisor) { create(:supervisor, casa_org: organization) } + let(:volunteer) { create(:volunteer, casa_org: organization) } + let(:casa_case) { create(:casa_case, casa_org: organization) } + let(:contact_type) { create(:contact_type, casa_org: organization) } + + let(:params) do + { + draw: "1", + start: "0", + length: "10", + search: {value: search_term}, + order: {"0" => {column: order_column, dir: order_direction}}, + columns: { + "0" => {name: "occurred_at", orderable: "true"}, + "1" => {name: "contact_made", orderable: "true"}, + "2" => {name: "medium_type", orderable: "true"}, + "3" => {name: "duration_minutes", orderable: "true"} + } + } + end + + let(:search_term) { "" } + let(:order_column) { "0" } + let(:order_direction) { "desc" } + let(:base_relation) { organization.case_contacts } + + subject(:datatable) { described_class.new(base_relation, params) } + + describe "#data" do + let!(:case_contact) do + create(:case_contact, + casa_case: casa_case, + creator: volunteer, + occurred_at: 2.days.ago, + contact_made: true, + medium_type: "in-person", + duration_minutes: 60, + notes: "Test notes") + end + + let!(:contact_topic) { create(:contact_topic, casa_org: organization) } + + before do + case_contact.contact_types << contact_type + create(:contact_topic_answer, case_contact: case_contact, contact_topic: contact_topic) + end + + it "returns an array of case contact data" do + expect(datatable.as_json[:data]).to be_an(Array) + end + + it "includes case contact attributes" do + contact_data = datatable.as_json[:data].first + + expect(contact_data[:id]).to eq(case_contact.id.to_s) + expect(contact_data[:contact_made]).to eq("true") + expect(contact_data[:medium_type]).to eq("In Person") + expect(contact_data[:duration_minutes]).to eq("60") + end + + it "includes formatted occurred_at date" do + contact_data = datatable.as_json[:data].first + expected_date = I18n.l(case_contact.occurred_at, format: :full, default: nil) + + expect(contact_data[:occurred_at]).to eq(expected_date) + end + + it "includes casa_case data" do + contact_data = datatable.as_json[:data].first + + expect(contact_data[:casa_case][:id]).to eq(casa_case.id.to_s) + expect(contact_data[:casa_case][:case_number]).to eq(casa_case.case_number) + end + + it "includes contact_types as comma-separated string" do + contact_data = datatable.as_json[:data].first + + expect(contact_data[:contact_types]).to include(contact_type.name) + end + + it "includes creator data" do + contact_data = datatable.as_json[:data].first + + expect(contact_data[:creator][:id]).to eq(volunteer.id.to_s) + expect(contact_data[:creator][:display_name]).to eq(volunteer.display_name) + expect(contact_data[:creator][:email]).to eq(volunteer.email) + expect(contact_data[:creator][:role]).to eq("Volunteer") + end + + it "includes contact_topics as pipe-separated string" do + contact_data = datatable.as_json[:data].first + + expect(contact_data[:contact_topics]).to include(contact_topic.question) + end + + it "includes is_draft status" do + contact_data = datatable.as_json[:data].first + + expect(contact_data[:is_draft]).to eq((!case_contact.active?).to_s) + end + + context "when case_contact has no casa_case (draft)" do + let!(:draft_contact) do + build(:case_contact, + casa_case: nil, + creator: volunteer, + occurred_at: 1.day.ago).tap do |cc| + cc.save(validate: false) + end + end + + it "handles nil casa_case gracefully" do + draft_data = datatable.as_json[:data].find { |d| d[:id] == draft_contact.id.to_s } + + # The sanitize method converts nil to empty string + expect(draft_data[:casa_case][:id]).to eq("") + expect(draft_data[:casa_case][:case_number]).to eq("") + end + end + + context "with followups" do + it "sets has_followup to true when requested followup exists" do + create(:followup, case_contact: case_contact, status: "requested") + + contact_data = datatable.as_json[:data].first + expect(contact_data[:has_followup]).to eq("true") + end + + it "sets has_followup to false when no requested followup exists" do + contact_data = datatable.as_json[:data].first + expect(contact_data[:has_followup]).to eq("false") + end + + it "sets has_followup to false when followup is resolved" do + create(:followup, case_contact: case_contact, status: "resolved") + + contact_data = datatable.as_json[:data].first + expect(contact_data[:has_followup]).to eq("false") + end + end + end + + describe "search functionality" do + let!(:john_contact) do + create(:case_contact, + casa_case: casa_case, + creator: create(:volunteer, display_name: "John Doe", email: "john@example.com"), + notes: "Meeting with youth") + end + + let!(:jane_contact) do + create(:case_contact, + casa_case: create(:casa_case, casa_org: organization, case_number: "CASA-2024-001"), + creator: create(:volunteer, display_name: "Jane Smith", email: "jane@example.com"), + notes: "Phone call") + end + + let!(:family_contact_type) { create(:contact_type, name: "Family", casa_org: organization) } + let!(:school_contact_type) { create(:contact_type, name: "School", casa_org: organization) } + + before do + john_contact.contact_types << family_contact_type + jane_contact.contact_types << school_contact_type + end + + context "searching by creator display_name" do + let(:search_term) { "John" } + + it "returns matching case contacts" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(john_contact.id.to_s) + expect(datatable.as_json[:data].map { |d| d[:id] }).not_to include(jane_contact.id.to_s) + end + end + + context "searching by creator email" do + let(:search_term) { "jane@example.com" } + + it "returns matching case contacts" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(jane_contact.id.to_s) + expect(datatable.as_json[:data].map { |d| d[:id] }).not_to include(john_contact.id.to_s) + end + end + + context "searching by case number" do + let(:search_term) { "2024-001" } + + it "returns matching case contacts" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(jane_contact.id.to_s) + expect(datatable.as_json[:data].map { |d| d[:id] }).not_to include(john_contact.id.to_s) + end + end + + context "searching by notes" do + let(:search_term) { "Meeting" } + + it "returns matching case contacts" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(john_contact.id.to_s) + expect(datatable.as_json[:data].map { |d| d[:id] }).not_to include(jane_contact.id.to_s) + end + end + + context "searching by contact_type name" do + let(:search_term) { "Family" } + + it "returns matching case contacts" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(john_contact.id.to_s) + expect(datatable.as_json[:data].map { |d| d[:id] }).not_to include(jane_contact.id.to_s) + end + end + + context "with case-insensitive search" do + let(:search_term) { "JOHN" } + + it "returns matching case contacts regardless of case" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(john_contact.id.to_s) + end + end + + context "with partial search term" do + let(:search_term) { "Smi" } + + it "returns matching case contacts with partial match" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(jane_contact.id.to_s) + end + end + + context "with blank search term" do + let(:search_term) { "" } + + it "returns all case contacts" do + expect(datatable.as_json[:data].map { |d| d[:id] }).to include(john_contact.id.to_s, jane_contact.id.to_s) + end + end + + context "with no matching results" do + let(:search_term) { "NonexistentName" } + + it "returns empty array" do + expect(datatable.as_json[:data]).to be_empty + end + end + end + + describe "ordering" do + let!(:old_contact) do + create(:case_contact, + casa_case: casa_case, + creator: volunteer, + occurred_at: 5.days.ago, + contact_made: false, + medium_type: "text/email", + duration_minutes: 30) + end + + let!(:recent_contact) do + create(:case_contact, + casa_case: casa_case, + creator: volunteer, + occurred_at: 1.day.ago, + contact_made: true, + medium_type: "in-person", + duration_minutes: 90) + end + + context "ordering by occurred_at" do + let(:order_column) { "0" } + + context "descending" do + let(:order_direction) { "desc" } + + it "orders contacts by occurred_at descending" do + ids = datatable.as_json[:data].map { |d| d[:id] } + expect(ids).to eq([recent_contact.id.to_s, old_contact.id.to_s]) + end + end + + context "ascending" do + let(:order_direction) { "asc" } + + it "orders contacts by occurred_at ascending" do + ids = datatable.as_json[:data].map { |d| d[:id] } + expect(ids).to eq([old_contact.id.to_s, recent_contact.id.to_s]) + end + end + end + + context "ordering by contact_made" do + let(:order_column) { "1" } + let(:order_direction) { "desc" } + + it "orders contacts by contact_made" do + ids = datatable.as_json[:data].map { |d| d[:id] } + expect(ids.first).to eq(recent_contact.id.to_s) + end + end + + context "ordering by medium_type" do + let(:order_column) { "2" } + let(:order_direction) { "asc" } + + it "orders contacts by medium_type" do + ids = datatable.as_json[:data].map { |d| d[:id] } + expect(ids.first).to eq(recent_contact.id.to_s) + end + end + + context "ordering by duration_minutes" do + let(:order_column) { "3" } + let(:order_direction) { "desc" } + + it "orders contacts by duration_minutes" do + ids = datatable.as_json[:data].map { |d| d[:id] } + expect(ids).to eq([recent_contact.id.to_s, old_contact.id.to_s]) + end + end + end + + describe "pagination" do + let!(:contacts) do + 25.times.map do |i| + create(:case_contact, + casa_case: casa_case, + creator: volunteer, + occurred_at: i.days.ago) + end + end + + context "first page" do + let(:params) do + super().merge(start: "0", length: "10") + end + + it "returns first 10 records" do + expect(datatable.as_json[:data].length).to eq(10) + end + + it "returns correct recordsTotal" do + expect(datatable.as_json[:recordsTotal]).to eq(25) + end + + it "returns correct recordsFiltered" do + expect(datatable.as_json[:recordsFiltered]).to eq(25) + end + end + + context "second page" do + let(:params) do + super().merge(start: "10", length: "10") + end + + it "returns next 10 records" do + expect(datatable.as_json[:data].length).to eq(10) + end + end + + context "last page with partial results" do + let(:params) do + super().merge(start: "20", length: "10") + end + + it "returns remaining 5 records" do + expect(datatable.as_json[:data].length).to eq(5) + end + end + + context "with search filtering" do + let!(:searchable_contact) do + create(:case_contact, + casa_case: casa_case, + creator: create(:volunteer, display_name: "UniqueSearchName", casa_org: organization), + occurred_at: 1.day.ago) + end + + let(:search_term) { "UniqueSearchName" } + + it "paginates filtered results" do + expect(datatable.as_json[:data].length).to eq(1) + expect(datatable.as_json[:recordsFiltered]).to eq(1) + expect(datatable.as_json[:recordsTotal]).to eq(26) + end + end + end + + describe "#as_json" do + let!(:case_contact) do + create(:case_contact, casa_case: casa_case, creator: volunteer) + end + + it "returns hash with data, recordsFiltered, and recordsTotal" do + json = datatable.as_json + + expect(json).to have_key(:data) + expect(json).to have_key(:recordsFiltered) + expect(json).to have_key(:recordsTotal) + end + + it "sanitizes HTML in data" do + contact_with_html = create(:case_contact, + casa_case: casa_case, + creator: volunteer, + notes: "") + + json = datatable.as_json + contact_data = json[:data].find { |d| d[:id] == contact_with_html.id.to_s } + + # Note: The sanitize method in ApplicationDatatable should escape HTML + expect(contact_data).to be_present + end + end + + describe "associations loading" do + let!(:contacts) do + 10.times.map do |i| + contact = create(:case_contact, + casa_case: create(:casa_case, casa_org: organization), + creator: create(:volunteer, casa_org: organization), + occurred_at: i.days.ago) + + contact_type = create(:contact_type, casa_org: organization) + contact.contact_types << contact_type + + contact_topic = create(:contact_topic, casa_org: organization) + create(:contact_topic_answer, case_contact: contact, contact_topic: contact_topic) + + contact + end + end + + it "loads all associations efficiently with includes" do + # This test verifies that the datatable returns data successfully + # with proper includes to prevent N+1 queries + json = datatable.as_json + + expect(json[:data].length).to eq(10) + expect(json[:data].first).to have_key(:contact_types) + expect(json[:data].first).to have_key(:contact_topics) + expect(json[:data].first).to have_key(:creator) + expect(json[:data].first).to have_key(:casa_case) + end + end +end diff --git a/spec/policies/case_contact_policy_spec.rb b/spec/policies/case_contact_policy_spec.rb index 815713b9d8..5680247c39 100644 --- a/spec/policies/case_contact_policy_spec.rb +++ b/spec/policies/case_contact_policy_spec.rb @@ -118,6 +118,20 @@ end end + permissions :datatable? do + it "allows casa_admins" do + expect(subject).to permit(casa_admin) + end + + it "allows supervisors" do + expect(subject).to permit(supervisor) + end + + it "allows volunteers" do + expect(subject).to permit(volunteer) + end + end + permissions :drafts? do it "allows casa_admins" do expect(subject).to permit(casa_admin) 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 cc413bd019..fb8f7e28b8 100644 --- a/spec/requests/case_contacts/case_contacts_new_design_spec.rb +++ b/spec/requests/case_contacts/case_contacts_new_design_spec.rb @@ -63,5 +63,120 @@ expect(recent_index).to be < past_index end end + + describe "POST /datatable" do + let!(:casa_case) { create(:casa_case, casa_org: organization) } + let!(:case_contact) { create(:case_contact, :active, casa_case: casa_case, occurred_at: 3.days.ago) } + + let(:datatable_params) do + { + draw: "1", + start: "0", + length: "10", + search: {value: ""}, + order: {"0" => {column: "0", dir: "desc"}}, + columns: { + "0" => {name: "occurred_at", orderable: "true"} + } + } + end + + context "when user is authorized" do + it "returns JSON with case contacts data" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + expect(response).to have_http_status(:success) + expect(response.content_type).to include("application/json") + + json = JSON.parse(response.body, symbolize_names: true) + expect(json).to have_key(:data) + expect(json).to have_key(:recordsTotal) + expect(json).to have_key(:recordsFiltered) + end + + it "includes case contact in the data array" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + expect(json[:data]).to be_an(Array) + expect(json[:data].first[:id]).to eq(case_contact.id.to_s) + end + + it "handles search parameter" do + searchable_contact = create(:case_contact, :active, + casa_case: casa_case, + creator: create(:volunteer, display_name: "John Doe", casa_org: organization)) + + search_params = datatable_params.merge(search: {value: "John"}) + post datatable_case_contacts_new_design_path, params: search_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + ids = json[:data].map { |d| d[:id] } + expect(ids).to include(searchable_contact.id.to_s) + end + end + + context "when user is a volunteer" do + let(:volunteer) { create(:volunteer, casa_org: organization) } + + before { sign_in volunteer } + + it "allows access to datatable endpoint" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + expect(response).to have_http_status(:success) + end + + it "only returns case contacts created by the volunteer" do + volunteer_contact = create(:case_contact, :active, casa_case: casa_case, creator: volunteer) + other_volunteer_contact = create(:case_contact, :active, casa_case: casa_case, + creator: create(:volunteer, casa_org: organization)) + + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + ids = json[:data].map { |d| d[:id] } + + expect(ids).to include(volunteer_contact.id.to_s) + expect(ids).not_to include(other_volunteer_contact.id.to_s) + end + end + + context "when user is a supervisor" do + let(:supervisor) { create(:supervisor, casa_org: organization) } + + before { sign_in supervisor } + + it "allows access to datatable endpoint" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + expect(response).to have_http_status(:success) + end + + it "returns all case contacts in the organization" do + contact1 = create(:case_contact, :active, casa_case: casa_case, + creator: create(:volunteer, casa_org: organization)) + contact2 = create(:case_contact, :active, casa_case: casa_case, + creator: create(:volunteer, casa_org: organization)) + + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + ids = json[:data].map { |d| d[:id] } + + expect(ids).to include(contact1.id.to_s, contact2.id.to_s) + end + end + + context "when user is not authenticated" do + before { sign_out admin } + + it "returns unauthorized status" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end end end