diff --git a/app/assets/stylesheets/pages/case_contacts.scss b/app/assets/stylesheets/pages/case_contacts.scss index 67a1a4fc71..da19ae5936 100644 --- a/app/assets/stylesheets/pages/case_contacts.scss +++ b/app/assets/stylesheets/pages/case_contacts.scss @@ -145,3 +145,33 @@ .cc-italic { font-style: italic; } + +.expand-toggle { + display: inline-block; + transition: transform 0.3s; + + &.expanded { + transform: rotate(180deg); + } +} + +.expanded-content { + padding: 0.75rem 1rem; + + .expanded-topic { + margin-bottom: 0.75rem; + + strong { + display: block; + } + + p { + margin: 0.25rem 0 0; + } + } +} + +.expanded-empty { + padding: 0.75rem 1rem; + margin: 0; +} diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb index bfeb930877..7975c16407 100644 --- a/app/datatables/case_contact_datatable.rb +++ b/app/datatables/case_contact_datatable.rb @@ -29,7 +29,11 @@ def data }, contact_made: case_contact.contact_made, duration_minutes: case_contact.duration_minutes, - contact_topics: case_contact.contact_topics.map(&:question).join(" | "), + contact_topics: case_contact.contact_topics.map(&:question), + contact_topic_answers: case_contact.contact_topic_answers + .reject { |a| a.value.blank? } + .map { |a| {question: a.contact_topic&.question, value: a.value} }, + notes: case_contact.notes.presence, is_draft: !case_contact.active?, has_followup: case_contact.followups.requested.exists? } @@ -44,7 +48,7 @@ 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) + .includes(:contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic) .order(order_clause) .order(:id) end diff --git a/app/javascript/__tests__/dashboard.test.js b/app/javascript/__tests__/dashboard.test.js index 3f5a33e0d9..596693055b 100644 --- a/app/javascript/__tests__/dashboard.test.js +++ b/app/javascript/__tests__/dashboard.test.js @@ -154,7 +154,7 @@ describe('defineCaseContactsTable', () => { it('renders chevron-down icon', () => { const rendered = columns[1].render(null, 'display', {}) - expect(rendered).toBe('') + expect(rendered).toBe('') }) }) @@ -309,9 +309,33 @@ describe('defineCaseContactsTable', () => { expect(columns[8].orderable).toBe(false) }) - it('renders contact topics string', () => { - expect(columns[8].render('Topic 1 | Topic 2')).toBe('Topic 1 | Topic 2') + it('renders each topic as a pill badge', () => { + const rendered = columns[8].render(['Topic 1', 'Topic 2']) + expect(rendered).toContain('Topic 1') + expect(rendered).toContain('Topic 2') + }) + + it('renders empty string when there are no topics', () => { expect(columns[8].render(null)).toBe('') + expect(columns[8].render([])).toBe('') + }) + + it('shows only the first two topics when there are more than two', () => { + const rendered = columns[8].render(['A', 'B', 'C', 'D']) + expect(rendered).toContain('>A<') + expect(rendered).toContain('>B<') + expect(rendered).not.toContain('>C<') + expect(rendered).not.toContain('>D<') + }) + + it('shows a +N More badge for overflow topics', () => { + const rendered = columns[8].render(['A', 'B', 'C', 'D']) + expect(rendered).toContain('+2 More') + }) + + it('does not show an overflow badge when there are two or fewer topics', () => { + expect(columns[8].render(['A', 'B'])).not.toContain('More') + expect(columns[8].render(['A'])).not.toContain('More') }) }) diff --git a/app/javascript/src/dashboard.js b/app/javascript/src/dashboard.js index 56989d3493..51c9353b5a 100644 --- a/app/javascript/src/dashboard.js +++ b/app/javascript/src/dashboard.js @@ -3,8 +3,37 @@ const { Notifier } = require('./notifier') let pageNotifier +const MAX_VISIBLE_TOPIC_PILLS = 2 + +function buildTopicPills (topics) { + if (!topics || topics.length === 0) return '' + const visible = topics.slice(0, MAX_VISIBLE_TOPIC_PILLS) + const overflowCount = topics.length - visible.length + const pills = visible + .map(topic => `${topic}`) + .join(' ') + const overflowPill = overflowCount > 0 + ? ` +${overflowCount} More` + : '' + return pills + overflowPill +} + +function buildExpandedContent (data) { + const answers = (data.contact_topic_answers || []) + .map(answer => `
`) + .join('') + + const notes = data.notes && data.notes.trim() + ? `` + : '' + + if (!answers && !notes) return '' + + return `` +} + const defineCaseContactsTable = function () { - $('table#case_contacts').DataTable({ + const table = $('table#case_contacts').DataTable({ scrollX: true, searching: true, processing: true, @@ -36,7 +65,7 @@ const defineCaseContactsTable = function () { data: null, orderable: false, searchable: false, - render: () => '' + render: () => '' }, { // Date column (index 2) data: 'occurred_at', @@ -106,7 +135,7 @@ const defineCaseContactsTable = function () { { // Topics column (index 8) data: 'contact_topics', orderable: false, - render: (data) => data || '' + render: (data) => buildTopicPills(data) }, { // Draft column (index 9) data: 'is_draft', @@ -125,6 +154,19 @@ const defineCaseContactsTable = function () { } ] }) + + $('table#case_contacts tbody').on('click', '.expand-toggle', function () { + const tr = $(this).closest('tr') + const row = table.row(tr) + + if (row.child.isShown()) { + row.child.hide() + $(this).removeClass('expanded') + } else { + row.child(buildExpandedContent(row.data())).show() + $(this).addClass('expanded') + } + }) } $(() => { // JQuery's callback for the DOM loading 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 9aa06eeedb..16f085cc2d 100644 --- a/spec/requests/case_contacts/case_contacts_new_design_spec.rb +++ b/spec/requests/case_contacts/case_contacts_new_design_spec.rb @@ -177,6 +177,93 @@ expect(response).to have_http_status(:unauthorized) end end + + context "expanded content fields" do + let(:contact_topic) { create(:contact_topic, casa_org: organization) } + let(:case_contact_with_details) do + create(:case_contact, :active, casa_case: casa_case, notes: "Important follow-up") + end + + before do + create(:contact_topic_answer, + case_contact: case_contact_with_details, + contact_topic: contact_topic, + value: "Youth is doing well") + end + + it "includes contact_topic_answers in the response" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_with_details.id.to_s } + expect(record[:contact_topic_answers]).to be_an(Array) + expect(record[:contact_topic_answers].first[:value]).to eq("Youth is doing well") + end + + it "includes the topic question in contact_topic_answers" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_with_details.id.to_s } + expect(record[:contact_topic_answers].first[:question]).to eq(contact_topic.question) + end + + it "includes notes in the response" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_with_details.id.to_s } + expect(record[:notes]).to eq("Important follow-up") + end + + it "omits blank topic answer values" do + create(:contact_topic_answer, + case_contact: case_contact_with_details, + contact_topic: contact_topic, + value: "") + + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_with_details.id.to_s } + expect(record[:contact_topic_answers].pluck(:value)).to all(be_present) + end + + it "returns a blank value for notes when notes are empty" do + case_contact_without_notes = create(:case_contact, :active, casa_case: casa_case, notes: "") + + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_without_notes.id.to_s } + expect(record[:notes]).to be_blank + end + end + + context "contact_topics field" do + let(:contact_topic) { create(:contact_topic, casa_org: organization) } + let(:case_contact_with_topics) { create(:case_contact, :active, casa_case: casa_case) } + + before do + case_contact_with_topics.contact_topics << contact_topic + end + + it "returns contact_topics as an array of strings" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_with_topics.id.to_s } + expect(record[:contact_topics]).to be_an(Array) + end + + it "includes the topic question in the array" do + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + + json = JSON.parse(response.body, symbolize_names: true) + record = json[:data].find { |d| d[:id] == case_contact_with_topics.id.to_s } + expect(record[:contact_topics]).to include(contact_topic.question) + end + end end end 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 new file mode 100644 index 0000000000..3e05c44cd4 --- /dev/null +++ b/spec/system/case_contacts/case_contacts_new_design_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe "Case Contact Table Row Expansion", type: :system, js: true do + let(:organization) { create(:casa_org) } + let(:admin) { create(:casa_admin, casa_org: organization) } + let(:casa_case) { create(:casa_case, casa_org: organization) } + let(:contact_topic) { create(:contact_topic, casa_org: organization, question: "What was discussed?") } + let!(:case_contact) do + create(:case_contact, :active, casa_case: casa_case, notes: "Important follow-up needed") + end + + before do + create(:contact_topic_answer, + case_contact: case_contact, + contact_topic: contact_topic, + value: "Youth is doing well in school") + allow(Flipper).to receive(:enabled?).with(:new_case_contact_table).and_return(true) + sign_in admin + visit case_contacts_new_design_path + end + + it "shows the expanded content after clicking the chevron" do + find(".expand-toggle").click + + expect(page).to have_content("What was discussed?") + expect(page).to have_content("Youth is doing well in school") + end + + it "shows notes in the expanded content" do + find(".expand-toggle").click + + expect(page).to have_content("Additional Notes") + expect(page).to have_content("Important follow-up needed") + end + + it "hides the expanded content after clicking the chevron again" do + find(".expand-toggle").click + expect(page).to have_content("Youth is doing well in school") + + find(".expand-toggle").click + expect(page).to have_no_content("Youth is doing well in school") + end +end