diff --git a/app/controllers/case_contacts/case_contacts_new_design_controller.rb b/app/controllers/case_contacts/case_contacts_new_design_controller.rb index 81e6a2b4a4..1900cf7334 100644 --- a/app/controllers/case_contacts/case_contacts_new_design_controller.rb +++ b/app/controllers/case_contacts/case_contacts_new_design_controller.rb @@ -10,7 +10,7 @@ def index def datatable authorize CaseContact case_contacts = policy_scope(current_organization.case_contacts) - datatable = CaseContactDatatable.new case_contacts, params + datatable = CaseContactDatatable.new(case_contacts, params, current_user) render json: datatable end diff --git a/app/controllers/case_contacts/followups_controller.rb b/app/controllers/case_contacts/followups_controller.rb index cd09e152ad..9da822b590 100644 --- a/app/controllers/case_contacts/followups_controller.rb +++ b/app/controllers/case_contacts/followups_controller.rb @@ -7,7 +7,10 @@ def create note = simple_followup_params[:note] FollowupService.create_followup(case_contact, current_user, note) - redirect_to casa_case_path(case_contact.casa_case) + respond_to do |format| + format.html { redirect_to casa_case_path(case_contact.casa_case) } + format.json { head :no_content } + end end def resolve @@ -17,7 +20,10 @@ def resolve @followup.resolved! create_notification - redirect_to casa_case_path(@followup.case_contact.casa_case) + respond_to do |format| + format.html { redirect_to casa_case_path(@followup.case_contact.casa_case) } + format.json { head :no_content } + end end private diff --git a/app/controllers/case_contacts_controller.rb b/app/controllers/case_contacts_controller.rb index 5f4275ea82..c32204fc1e 100644 --- a/app/controllers/case_contacts_controller.rb +++ b/app/controllers/case_contacts_controller.rb @@ -44,8 +44,14 @@ def destroy authorize @case_contact @case_contact.destroy - flash[:notice] = "Contact is successfully deleted." - redirect_to request.referer + + respond_to do |format| + format.html do + flash[:notice] = "Contact is successfully deleted." + redirect_to request.referer + end + format.json { head :no_content } + end end def restore diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb index 34176920c6..4f5b28a8ff 100644 --- a/app/datatables/case_contact_datatable.rb +++ b/app/datatables/case_contact_datatable.rb @@ -8,10 +8,20 @@ class CaseContactDatatable < ApplicationDatatable duration_minutes ].freeze + def initialize(base_relation, params, current_user) + super(base_relation, params) + @current_user = current_user + end + private + attr_reader :current_user + def data records.map do |case_contact| + policy = CaseContactPolicy.new(current_user, case_contact) + requested_followup = case_contact.followups.find(&:requested?) + { id: case_contact.id, occurred_at: I18n.l(case_contact.occurred_at, format: :full, default: nil), @@ -35,7 +45,11 @@ def data .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.any?(&:requested?) + has_followup: requested_followup.present?, + can_edit: policy.update?, + can_destroy: policy.destroy?, + edit_path: Rails.application.routes.url_helpers.edit_case_contact_path(case_contact), + followup_id: requested_followup&.id } end end @@ -49,6 +63,7 @@ def raw_records .joins("INNER JOIN users creators ON creators.id = case_contacts.creator_id") .left_joins(:casa_case) .includes(:casa_case, :contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic) + .preload(:casa_org, :creator_casa_org) .order(order_clause) .order(:id) end diff --git a/app/javascript/__tests__/dashboard.test.js b/app/javascript/__tests__/dashboard.test.js index 9a289bd15d..7a19aa0f77 100644 --- a/app/javascript/__tests__/dashboard.test.js +++ b/app/javascript/__tests__/dashboard.test.js @@ -3,7 +3,17 @@ * @jest-environment jsdom */ +import Swal from 'sweetalert2' import { defineCaseContactsTable } from '../src/dashboard' +import { fireSwalFollowupAlert } from '../src/case_contact' +jest.mock('sweetalert2', () => ({ + __esModule: true, + default: { fire: jest.fn() } +})) + +jest.mock('../src/case_contact', () => ({ + fireSwalFollowupAlert: jest.fn() +})) // Mock DataTable const mockDataTable = jest.fn() @@ -382,10 +392,259 @@ describe('defineCaseContactsTable', () => { expect(columns[10].searchable).toBe(false) }) - it('renders ellipsis icon', () => { - const rendered = columns[10].render(null, 'display', {}) + it('renders a button toggle with aria-label containing the contact date', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('class="fas fa-ellipsis-v"') + expect(rendered).toContain('aria-label="Actions for case contact on July 01, 2024"') + expect(rendered).toContain('type="button"') + }) + + it('renders the ellipsis icon as aria-hidden', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('aria-hidden="true"') + }) + + it('renders Edit item when can_edit is "true"', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('href="/case_contacts/1/edit"') + expect(rendered).toContain('Edit') + }) + + it('renders Edit as disabled when can_edit is "false"', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('Edit') + expect(rendered).toContain('disabled') + expect(rendered).toContain('aria-disabled="true"') + expect(rendered).not.toContain('href="/case_contacts/1/edit"') + }) + + it('renders Delete item when can_destroy is "true"', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('cc-delete-action') + expect(rendered).toContain('data-id="1"') + expect(rendered).toContain('Delete') + }) + + it('renders Delete as disabled when can_destroy is "false"', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('Delete') + expect(rendered).toContain('disabled') + expect(rendered).toContain('aria-disabled="true"') + expect(rendered).not.toContain('cc-delete-action') + }) + + it('renders Set Reminder when followup_id is empty', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('cc-set-reminder-action') + expect(rendered).toContain('Set Reminder') + expect(rendered).not.toContain('Resolve Reminder') + }) + + it('renders Resolve Reminder when followup_id is present', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '42' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toContain('cc-resolve-reminder-action') + expect(rendered).toContain('data-followup-id="42"') + expect(rendered).toContain('Resolve Reminder') + expect(rendered).not.toContain('Set Reminder') + }) + + it('always renders the reminder menu item', () => { + const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } + const rendered = columns[10].render(null, 'display', row) + + expect(rendered).toMatch(/Set Reminder|Resolve Reminder/) + }) + }) + }) + + describe('click handlers', () => { + let mockAjaxReload + let mockTableInstance + + const clickActionButton = (action, attrs = {}) => { + const dataAttrs = Object.entries(attrs).map(([k, v]) => `data-${k}="${v}"`).join(' ') + $('table#case_contacts tbody').append( + `` + ) + $(`.cc-${action}-action`).trigger('click') + } + + beforeEach(() => { + mockAjaxReload = jest.fn() + mockTableInstance = { ajax: { reload: mockAjaxReload } } + mockDataTable.mockReturnValue(mockTableInstance) + + // Add CSRF meta tag + document.head.innerHTML = '' + + defineCaseContactsTable() + }) + + afterEach(() => { + Swal.fire.mockReset() + fireSwalFollowupAlert.mockReset() + }) + + describe('Delete action', () => { + it('shows a SweetAlert confirmation dialog when cc-delete-action is clicked', () => { + Swal.fire.mockResolvedValue({ isConfirmed: false }) + + clickActionButton('delete', { id: '42' }) + + expect(Swal.fire).toHaveBeenCalled() + }) + + it('sends DELETE request when confirmed', async () => { + Swal.fire.mockResolvedValue({ isConfirmed: true }) + const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('delete', { id: '42' }) + + await Promise.resolve() + + expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/case_contacts/42', + type: 'DELETE', + headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } + })) + expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType') + + ajaxSpy.mockRestore() + }) + + it('does not send DELETE request when cancelled', async () => { + Swal.fire.mockResolvedValue({ isConfirmed: false }) + const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation() + + clickActionButton('delete', { id: '42' }) + + await Promise.resolve() + + expect(ajaxSpy).not.toHaveBeenCalled() + + ajaxSpy.mockRestore() + }) + + it('reloads the DataTable without resetting pagination after successful delete', async () => { + Swal.fire.mockResolvedValue({ isConfirmed: true }) + jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('delete', { id: '42' }) + + await Promise.resolve() + + expect(mockAjaxReload).toHaveBeenCalledWith(null, false) + }) + }) + + describe('Set Reminder action', () => { + it('calls fireSwalFollowupAlert when cc-set-reminder-action is clicked', () => { + fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false }) + + clickActionButton('set-reminder', { id: '5' }) + + expect(fireSwalFollowupAlert).toHaveBeenCalled() + }) + + it('posts to the followups endpoint with CSRF header when confirmed without a note', async () => { + fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true }) + const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('set-reminder', { id: '5' }) + + await Promise.resolve() + + expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/case_contacts/5/followups', + type: 'POST', + data: {}, + headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } + })) + + ajaxSpy.mockRestore() + }) + + it('posts with note when confirmed with a note', async () => { + fireSwalFollowupAlert.mockResolvedValue({ value: 'My note', isConfirmed: true }) + const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('set-reminder', { id: '5' }) + + await Promise.resolve() + + expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/case_contacts/5/followups', + type: 'POST', + data: { note: 'My note' }, + headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } + })) + + ajaxSpy.mockRestore() + }) + + it('does not post when cancelled', async () => { + fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false }) + const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation() + + clickActionButton('set-reminder', { id: '5' }) + + await Promise.resolve() + + expect(ajaxSpy).not.toHaveBeenCalled() + + ajaxSpy.mockRestore() + }) + + it('reloads the DataTable without resetting pagination after creating a reminder', async () => { + fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true }) + jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('set-reminder', { id: '5' }) + + await Promise.resolve() + + expect(mockAjaxReload).toHaveBeenCalledWith(null, false) + }) + }) + + describe('Resolve Reminder action', () => { + it('sends PATCH request when cc-resolve-reminder-action is clicked', () => { + const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' }) + + expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/followups/42/resolve', + type: 'PATCH', + headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } + })) + expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType') + + ajaxSpy.mockRestore() + }) + + it('reloads the DataTable without resetting pagination after resolving a reminder', () => { + jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) + + clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' }) - expect(rendered).toBe('') + expect(mockAjaxReload).toHaveBeenCalledWith(null, false) }) }) }) diff --git a/app/javascript/src/case_contact.js b/app/javascript/src/case_contact.js index 77ee094d24..c2837c683d 100644 --- a/app/javascript/src/case_contact.js +++ b/app/javascript/src/case_contact.js @@ -54,5 +54,6 @@ $(() => { // JQuery's callback for the DOM loading }) export { - convertDateToSystemTimeZone + convertDateToSystemTimeZone, + fireSwalFollowupAlert } diff --git a/app/javascript/src/dashboard.js b/app/javascript/src/dashboard.js index a9115a0dcc..af0932ed6e 100644 --- a/app/javascript/src/dashboard.js +++ b/app/javascript/src/dashboard.js @@ -1,5 +1,7 @@ /* global alert */ /* global $ */ +import Swal from 'sweetalert2' +import { fireSwalFollowupAlert } from './case_contact' const { Notifier } = require('./notifier') let pageNotifier @@ -150,7 +152,43 @@ const defineCaseContactsTable = function () { data: null, orderable: false, searchable: false, - render: () => '' + render: (_data, _type, row) => { + const buttonId = `cc-actions-btn-${row.id}` + const label = `Actions for case contact${row.occurred_at ? ' on ' + row.occurred_at : ''}` + + const editItem = row.can_edit === 'true' + ? `
  • Edit
  • ` + : '
  • Edit
  • ' + + const deleteItem = row.can_destroy === 'true' + ? `
  • ` + : '
  • ' + + const reminderItem = row.followup_id + ? `
  • ` + : `
  • ` + + return ` + + ` + } } ] }) @@ -167,6 +205,49 @@ const defineCaseContactsTable = function () { $(this).addClass('expanded').attr('aria-expanded', 'true') } }) + + const csrfToken = () => $('meta[name="csrf-token"]').attr('content') + + $('table#case_contacts').on('click', '.cc-delete-action', async function () { + const id = $(this).data('id') + const { isConfirmed } = await Swal.fire({ + title: 'Delete this contact?', + showCancelButton: true, + confirmButtonText: 'Delete', + confirmButtonColor: '#dc3545' + }) + if (!isConfirmed) return + $.ajax({ + url: `/case_contacts/${id}`, + type: 'DELETE', + headers: { 'X-CSRF-Token': csrfToken(), Accept: 'application/json' }, + success: () => table.ajax.reload(null, false) + }) + }) + + $('table#case_contacts').on('click', '.cc-set-reminder-action', async function () { + const id = $(this).data('id') + const { value: text, isConfirmed } = await fireSwalFollowupAlert() + if (!isConfirmed) return + const params = text ? { note: text } : {} + $.ajax({ + url: `/case_contacts/${id}/followups`, + type: 'POST', + data: params, + headers: { 'X-CSRF-Token': csrfToken(), Accept: 'application/json' }, + success: () => table.ajax.reload(null, false) + }) + }) + + $('table#case_contacts').on('click', '.cc-resolve-reminder-action', function () { + const followupId = $(this).data('followup-id') + $.ajax({ + url: `/followups/${followupId}/resolve`, + type: 'PATCH', + headers: { 'X-CSRF-Token': csrfToken(), Accept: 'application/json' }, + success: () => table.ajax.reload(null, false) + }) + }) } $(() => { // JQuery's callback for the DOM loading diff --git a/spec/datatables/case_contact_datatable_spec.rb b/spec/datatables/case_contact_datatable_spec.rb index a68ec02be8..8a943c81f0 100644 --- a/spec/datatables/case_contact_datatable_spec.rb +++ b/spec/datatables/case_contact_datatable_spec.rb @@ -30,7 +30,9 @@ let(:order_direction) { "desc" } let(:base_relation) { organization.case_contacts } - subject(:datatable) { described_class.new(base_relation, params) } + let(:current_user) { create(:casa_admin, casa_org: organization) } + + subject(:datatable) { described_class.new(base_relation, params, current_user) } describe "#data" do let!(:case_contact) do @@ -105,6 +107,71 @@ expect(contact_data[:is_draft]).to eq((!case_contact.active?).to_s) end + context "with action metadata" do + it "includes all expected action metadata keys" do + contact_data = datatable.as_json[:data].first + expect(contact_data).to include(:can_edit, :can_destroy, :edit_path, :followup_id, :has_followup) + end + + it "includes edit_path for the contact" do + contact_data = datatable.as_json[:data].first + expected_path = Rails.application.routes.url_helpers.edit_case_contact_path(case_contact) + expect(contact_data[:edit_path]).to eq(expected_path) + end + + it "includes followup_id as empty string when no requested followup exists" do + contact_data = datatable.as_json[:data].first + expect(contact_data[:followup_id]).to eq("") + end + + it "includes followup_id when a requested followup exists" do + followup = create(:followup, case_contact: case_contact, status: "requested") + contact_data = datatable.as_json[:data].first + expect(contact_data[:followup_id]).to eq(followup.id.to_s) + end + + it "does not include followup_id for a resolved followup" do + create(:followup, case_contact: case_contact, status: "resolved") + contact_data = datatable.as_json[:data].first + expect(contact_data[:followup_id]).to eq("") + end + end + + context "with permission flags" do + context "when current_user is an admin" do + it "sets can_edit to true" do + expect(datatable.as_json[:data].first[:can_edit]).to eq("true") + end + + it "sets can_destroy to true" do + expect(datatable.as_json[:data].first[:can_destroy]).to eq("true") + end + end + + context "when current_user is the volunteer who created the contact" do + let(:current_user) { volunteer } + + it "sets can_edit to true" do + expect(datatable.as_json[:data].first[:can_edit]).to eq("true") + end + + it "sets can_destroy to false for an active contact" do + expect(datatable.as_json[:data].first[:can_destroy]).to eq("false") + end + end + + context "when current_user is the volunteer who created a draft contact" do + let(:current_user) { volunteer } + let!(:case_contact) do + create(:case_contact, casa_case: casa_case, creator: volunteer, status: "started") + end + + it "sets can_destroy to true for their own draft" do + expect(datatable.as_json[:data].first[:can_destroy]).to eq("true") + end + end + end + context "when case_contact has no casa_case (draft)" do let!(:draft_contact) do build(:case_contact, @@ -414,6 +481,32 @@ end end + describe "N+1 queries" do + let!(:contacts) do + 3.times.map do + create(:case_contact, + casa_case: create(:casa_case, casa_org: organization), + creator: create(:volunteer, casa_org: organization)) + end + end + + it "does not trigger N+1 queries for casa_org when computing per-row policy permissions" do + casa_org_queries = [] + subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + casa_org_queries << payload[:sql] if payload[:sql].match?(/SELECT.*casa_orgs/) + end + + datatable.as_json + + ActiveSupport::Notifications.unsubscribe(subscription) + + # With proper eager loading, casa_orgs is fetched in at most 2 batch queries + # (one for :casa_org through casa_case, one for :creator_casa_org through creator). + # An N+1 would fire 1 query per record (3+ here). + expect(casa_org_queries.length).to be <= 2 + end + end + describe "associations loading" do let!(:contacts) do 10.times.map do |i| 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 16f085cc2d..6f4348ce78 100644 --- a/spec/requests/case_contacts/case_contacts_new_design_spec.rb +++ b/spec/requests/case_contacts/case_contacts_new_design_spec.rb @@ -264,6 +264,68 @@ expect(record[:contact_topics]).to include(contact_topic.question) end end + + context "with permission flags and action metadata" do + let(:volunteer) { create(:volunteer, casa_org: organization) } + let!(:active_contact) { create(:case_contact, :active, casa_case: casa_case, creator: volunteer) } + let!(:draft_contact) { create(:case_contact, casa_case: casa_case, creator: volunteer, status: "started") } + + def post_datatable + post datatable_case_contacts_new_design_path, params: datatable_params, as: :json + end + + def row_for(contact_id) + json = JSON.parse(response.body, symbolize_names: true) + json[:data].find { |row| row[:id] == contact_id.to_s } + end + + context "when signed in as admin" do + it "includes can_edit as true" do + post_datatable + expect(row_for(active_contact.id)[:can_edit]).to eq("true") + end + + it "includes can_destroy as true" do + post_datatable + expect(row_for(active_contact.id)[:can_destroy]).to eq("true") + end + + it "includes edit_path for the contact" do + post_datatable + expect(row_for(active_contact.id)[:edit_path]).to eq(edit_case_contact_path(active_contact)) + end + + it "includes followup_id as empty when no followup exists" do + post_datatable + expect(row_for(active_contact.id)[:followup_id]).to eq("") + end + + it "includes followup_id when a requested followup exists" do + followup = create(:followup, case_contact: active_contact, status: :requested, creator: admin) + post_datatable + expect(row_for(active_contact.id)[:followup_id]).to eq(followup.id.to_s) + end + end + + context "when signed in as volunteer" do + before { sign_in volunteer } + + it "includes can_edit as true for their own contact" do + post_datatable + expect(row_for(active_contact.id)[:can_edit]).to eq("true") + end + + it "includes can_destroy as false for their own active contact" do + post_datatable + expect(row_for(active_contact.id)[:can_destroy]).to eq("false") + end + + it "includes can_destroy as true for their own draft contact" do + post_datatable + expect(row_for(draft_contact.id)[:can_destroy]).to eq("true") + end + end + end end end end diff --git a/spec/requests/case_contacts/followups_spec.rb b/spec/requests/case_contacts/followups_spec.rb index 49a6b486b7..6bf0594ced 100644 --- a/spec/requests/case_contacts/followups_spec.rb +++ b/spec/requests/case_contacts/followups_spec.rb @@ -27,6 +27,21 @@ expect(followup.note).to eq "Hello, world!" end + context "when requested as JSON" do + subject(:request) do + post case_contact_followups_path(case_contact), + params: params, + headers: {"Accept" => "application/json"} + + response + end + + it "returns 204 No Content" do + request + expect(response).to have_http_status(:no_content) + end + end + it "sends a Followup Notifier to case contact creator" do request followup = Followup.last @@ -66,6 +81,21 @@ expect { request }.to change { followup.reload.resolved? }.from(false).to(true) end + context "when requested as JSON" do + subject(:request) do + patch resolve_followup_path(followup), + headers: {"Accept" => "application/json"} + + response + end + + it "returns 204 No Content" do + followup + request + expect(response).to have_http_status(:no_content) + end + end + it "does not send Followup Notifier" do followup expect(FollowupResolvedNotifier).not_to receive(:with) diff --git a/spec/system/case_contacts/case_contacts_new_design_spec.rb b/spec/system/case_contacts/case_contacts_new_design_spec.rb index 3e05c44cd4..af2364a531 100644 --- a/spec/system/case_contacts/case_contacts_new_design_spec.rb +++ b/spec/system/case_contacts/case_contacts_new_design_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Case Contact Table Row Expansion", type: :system, js: true do +RSpec.describe "Case contacts new design", 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) } @@ -14,30 +14,196 @@ case_contact: case_contact, contact_topic: contact_topic, value: "Youth is doing well in school") + allow(Flipper).to receive(:enabled?).and_call_original 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 + shared_context "signed in as admin" do + before do + sign_in admin + visit case_contacts_new_design_path + end + end + + describe "row expansion" do + include_context "signed in as admin" + 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 + + describe "action menu" do + include_context "signed in as admin" + it "opens the dropdown when the ellipsis button is clicked" do + find(".cc-ellipsis-toggle").click + + expect(page).to have_css(".dropdown-menu.show") + end + + it "shows Edit in the menu" do + find(".cc-ellipsis-toggle").click + + expect(page).to have_text("Edit") + end + + it "shows Delete in the menu" do + find(".cc-ellipsis-toggle").click + + expect(page).to have_text("Delete") + end + + it "shows Set Reminder when no followup exists" do + find(".cc-ellipsis-toggle").click + + expect(page).to have_text("Set Reminder") + expect(page).to have_no_text("Resolve Reminder") + end + + it "shows Resolve Reminder when a requested followup exists" do + create(:followup, case_contact: case_contact, status: :requested, creator: admin) + visit case_contacts_new_design_path + + find(".cc-ellipsis-toggle").click + + expect(page).to have_text("Resolve Reminder") + expect(page).to have_no_text("Set Reminder") + end + + it "closes the dropdown when clicking outside" do + find(".cc-ellipsis-toggle").click + expect(page).to have_css(".dropdown-menu.show") + + find("h1").click + expect(page).to have_no_css(".dropdown-menu.show") + end + end + + describe "Edit action" do + include_context "signed in as admin" + it "navigates to the edit form when Edit is clicked" do + find(".cc-ellipsis-toggle").click + click_link "Edit" - expect(page).to have_content("What was discussed?") - expect(page).to have_content("Youth is doing well in school") + expect(page).to have_current_path(/case_contacts\/#{case_contact.id}\/form/) + end end - it "shows notes in the expanded content" do - find(".expand-toggle").click + describe "Delete action" do + include_context "signed in as admin" + let(:occurred_at_text) { I18n.l(case_contact.occurred_at, format: :full) } - expect(page).to have_content("Additional Notes") - expect(page).to have_content("Important follow-up needed") + it "removes the row after confirming the delete dialog" do + expect(page).to have_text(occurred_at_text) + + find(".cc-ellipsis-toggle").click + find(".cc-delete-action").click + click_button "Delete" + + expect(page).to have_no_text(occurred_at_text) + end + + it "leaves the row in place when the delete dialog is cancelled" do + expect(page).to have_text(occurred_at_text) + + find(".cc-ellipsis-toggle").click + find(".cc-delete-action").click + click_button "Cancel" + + expect(page).to have_text(occurred_at_text) + end + end + + describe "Set Reminder action" do + include_context "signed in as admin" + it "creates a followup and shows Resolve Reminder in the menu after confirming" do + find(".cc-ellipsis-toggle").click + find(".cc-set-reminder-action").click + click_button "Confirm" + + expect(page).to have_css("i.fas.fa-bell:not([style])") + + find(".cc-ellipsis-toggle").click + expect(page).to have_text("Resolve Reminder") + expect(page).to have_no_text("Set Reminder") + end + + it "does not create a followup when cancelled" do + find(".cc-ellipsis-toggle").click + find(".cc-set-reminder-action").click + click_button "Cancel" + + expect(case_contact.followups.reload).to be_empty + end 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") + describe "Resolve Reminder action" do + include_context "signed in as admin" + + let!(:followup) { create(:followup, case_contact: case_contact, status: :requested, creator: admin) } + + before { visit case_contacts_new_design_path } + + it "resolves the followup and shows Set Reminder in the menu afterwards" do + find(".cc-ellipsis-toggle").click + find(".cc-resolve-reminder-action").click + + expect(page).to have_css("i.fas.fa-bell[style*='opacity']") + + find(".cc-ellipsis-toggle").click + expect(page).to have_text("Set Reminder") + expect(page).to have_no_text("Resolve Reminder") + end + + it "marks the followup as resolved" do + find(".cc-ellipsis-toggle").click + find(".cc-resolve-reminder-action").click + + # Wait for reload to confirm the AJAX completed before checking DB + expect(page).to have_css("i.fas.fa-bell[style*='opacity']") + + expect(followup.reload.status).to eq("resolved") + end + end + + describe "permission states" do + let(:volunteer) { create(:volunteer, casa_org: organization) } + let(:casa_case_for_volunteer) { create(:casa_case, casa_org: organization) } + let!(:active_contact) { create(:case_contact, :active, casa_case: casa_case_for_volunteer, creator: volunteer, occurred_at: 5.days.ago) } + let!(:draft_contact) { create(:case_contact, casa_case: casa_case_for_volunteer, creator: volunteer, status: "started", occurred_at: 10.days.ago) } + + before do + sign_in volunteer + visit case_contacts_new_design_path + end + + it "shows Delete as disabled for an active contact" do + find("#cc-actions-btn-#{active_contact.id}").click + expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{active_contact.id}'].show") + expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{active_contact.id}'] button.dropdown-item.disabled", text: "Delete") + end - find(".expand-toggle").click - expect(page).to have_no_content("Youth is doing well in school") + it "shows Delete as enabled for a draft contact" do + find("#cc-actions-btn-#{draft_contact.id}").click + expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{draft_contact.id}'].show") + expect(page).to have_css(".dropdown-menu[aria-labelledby='cc-actions-btn-#{draft_contact.id}'] button.cc-delete-action", text: "Delete") + end end end